diff --git a/FEATURE_MATRIX.md b/FEATURE_MATRIX.md index 590e04f..7e3b124 100644 --- a/FEATURE_MATRIX.md +++ b/FEATURE_MATRIX.md @@ -31,7 +31,7 @@ OMP authority: `packages/coding-agent/src/session/agent-session.ts`, `session-ma | Capability | Source command/state | Desktop behavior | T3 reference | Priority | |---|---|---|---|---| -| List/recent/search/filter | session store and metadata | Virtualized left rail; project/host grouping; running/waiting/failed/unread badges; fuzzy search | `Sidebar.tsx`, sidebar logic/tests | Launch | +| List/recent/search/filter | session store and metadata | Bounded left rail; folder or flat organization; Priority, Last updated, or Manual order; title/folder/host search; attention/running/unread/error filters; pinned shortcuts; running/waiting/failed/unread badges | `Sidebar.tsx`, sidebar logic/tests | Launch | | New session | `/new` | Create in selected project/host; model/profile defaults visible before first prompt | draft routes and composer draft store | Launch | | Fast switch and tabs | session IDs and snapshots | One-click/keyboard switch; preserve draft, scroll anchor, panel widths/tabs, terminal focus; no white flash | T3 routes, `composerDraftStore`, `rightPanelStore`, terminal store | Launch | | Resume | `/resume` | Open existing session by stable ID/path; recover moved/missing files with explicit error | thread routing and reconnect supervisor | Launch | diff --git a/apps/site/src/docs/content.ts b/apps/site/src/docs/content.ts index d31e60c..bd95867 100644 --- a/apps/site/src/docs/content.ts +++ b/apps/site/src/docs/content.ts @@ -207,7 +207,20 @@ const localSessions: DocTopic = { }, { kind: "note", - text: "Removing a shortcut is view state for that T4 Code client, so desktop and phone can differ. Archived sessions remain visible, and their folder menu can restore the shortcut. A new or restored Current session always makes the folder visible. T4 Code still does not alias, pin, reorder, delete, or rename the underlying folder.", + text: "Removing, pinning, or manually ordering shortcuts is view state for that T4 Code client, so desktop and phone can differ. Archived sessions remain visible, and their folder menu can restore a removed shortcut. A new or restored Current session always makes the folder visible. T4 Code never moves, deletes, or renames the underlying folder.", + }, + { kind: "h2", id: "local-sessions-organize", text: "Organize a large session library" }, + { + kind: "p", + text: "Use the rail's organization menu to group sessions by working folder or combine them into one list. Sort by **Priority**, **Last updated**, or **Manual order**. Priority puts approval requests, questions, running work, unread completions, errors, and plans ahead of ordinary recent work in that order.", + }, + { + kind: "p", + text: "The rail search matches session titles, models, working folders, and host names. Quick filters narrow the list to Attention, Running, Unread, or Errors. Search and filters reset when T4 Code restarts so an old filter never makes work look missing.", + }, + { + kind: "p", + text: "Pin a session or working folder from its action menu to create a shortcut in the Pinned section. In Manual order, the same menus provide Move up and Move down actions. Large folders initially draw a bounded set of rows; **Show more** reveals the next set without forcing the entire library into the page at once.", }, { kind: "h2", diff --git a/apps/web/src/components/AppShell.tsx b/apps/web/src/components/AppShell.tsx index 2e0a544..c4cea9e 100644 --- a/apps/web/src/components/AppShell.tsx +++ b/apps/web/src/components/AppShell.tsx @@ -33,6 +33,13 @@ export function AppShell() { const railOverlayOpen = useWorkspace((state) => state.railOverlayOpen); const focusMode = useWorkspace((state) => state.focusMode); const sessionListView = useWorkspace((state) => state.sessionListView); + const railSort = useWorkspace((state) => state.railSort); + const railQuery = useWorkspace((state) => state.railQuery); + const railFilter = useWorkspace((state) => state.railFilter); + const projectManualOrder = useWorkspace((state) => state.projectManualOrder); + const sessionManualOrderByProjectId = useWorkspace( + (state) => state.sessionManualOrderByProjectId, + ); const projectExpandedById = useWorkspace((state) => state.projectExpandedById); const dismissedEmptyProjectIds = useWorkspace((state) => state.dismissedEmptyProjectIds); const lastVisitedAtBySessionId = useWorkspace((state) => state.lastVisitedAtBySessionId); @@ -52,12 +59,95 @@ export function AppShell() { lastVisitedAtBySessionId, "current", dismissedEmptyProjectIds, + { + filter: railFilter, + query: railQuery, + sort: railSort, + projectManualOrder, + sessionManualOrderByProjectId, + }, ), - [shellData, projectExpandedById, lastVisitedAtBySessionId, dismissedEmptyProjectIds], + [ + shellData, + projectExpandedById, + lastVisitedAtBySessionId, + dismissedEmptyProjectIds, + railFilter, + railQuery, + railSort, + projectManualOrder, + sessionManualOrderByProjectId, + ], ); const archivedGroups = useMemo( - () => buildProjectGroups(shellData, projectExpandedById, lastVisitedAtBySessionId, "archived"), - [shellData, projectExpandedById, lastVisitedAtBySessionId], + () => + buildProjectGroups( + shellData, + projectExpandedById, + lastVisitedAtBySessionId, + "archived", + {}, + { + filter: railFilter, + query: railQuery, + sort: railSort, + projectManualOrder, + sessionManualOrderByProjectId, + }, + ), + [ + shellData, + projectExpandedById, + lastVisitedAtBySessionId, + railFilter, + railQuery, + railSort, + projectManualOrder, + sessionManualOrderByProjectId, + ], + ); + const allCurrentGroups = useMemo( + () => + buildProjectGroups( + shellData, + projectExpandedById, + lastVisitedAtBySessionId, + "current", + dismissedEmptyProjectIds, + { sort: railSort, projectManualOrder, sessionManualOrderByProjectId }, + ), + [ + shellData, + projectExpandedById, + lastVisitedAtBySessionId, + dismissedEmptyProjectIds, + railSort, + projectManualOrder, + sessionManualOrderByProjectId, + ], + ); + const allArchivedGroups = useMemo( + () => + buildProjectGroups( + shellData, + projectExpandedById, + lastVisitedAtBySessionId, + "archived", + {}, + { sort: railSort, projectManualOrder, sessionManualOrderByProjectId }, + ), + [ + shellData, + projectExpandedById, + lastVisitedAtBySessionId, + railSort, + projectManualOrder, + sessionManualOrderByProjectId, + ], + ); + const allSessionGroups = useMemo( + () => [...allCurrentGroups, ...allArchivedGroups], + [allArchivedGroups, allCurrentGroups], ); const groups = sessionListView === "archived" ? archivedGroups : currentGroups; const currentCount = shellData.sessions.filter( @@ -137,7 +227,9 @@ export function AppShell() { } else if (action.kind === "toggle-terminal") { const activeId = state.activeSessionId; const activeSession = - activeId === null ? undefined : getShellData().sessions.find((session) => session.id === activeId); + activeId === null + ? undefined + : getShellData().sessions.find((session) => session.id === activeId); if (activeId !== null && activeSession?.archivedAt === undefined) { const view = selectSessionView(state, activeId); if (state.focusMode) { @@ -159,6 +251,13 @@ export function AppShell() { state.lastVisitedAtBySessionId, state.sessionListView, state.dismissedEmptyProjectIds, + { + filter: state.railFilter, + query: state.railQuery, + sort: state.railSort, + projectManualOrder: state.projectManualOrder, + sessionManualOrderByProjectId: state.sessionManualOrderByProjectId, + }, ), ); const sessionId = visible[action.index]; @@ -220,7 +319,7 @@ export function AppShell() {
{ const state = workspaceStore.getState(); state.setRailCollapsed(false); @@ -231,12 +330,14 @@ export function AppShell() { ) : (
@@ -285,12 +386,14 @@ export function AppShell() {
@@ -298,7 +401,7 @@ export function AppShell() { )} - + ); } diff --git a/apps/web/src/components/Rail.tsx b/apps/web/src/components/Rail.tsx index f8cd562..17591cd 100644 --- a/apps/web/src/components/Rail.tsx +++ b/apps/web/src/components/Rail.tsx @@ -22,15 +22,24 @@ import { Popover } from "@base-ui/react/popover"; import { useNavigate } from "@tanstack/react-router"; import { Archive, + ArrowDown, + ArrowUp, Cable, ChevronDown, ChevronRight, CircleStop, + Folder, Inbox, + LayoutList, + ListFilter, MoreHorizontal, Pencil, + Pin, + PinOff, Plus, RotateCcw, + Search, + SlidersHorizontal, Trash2, UsersRound, X, @@ -40,12 +49,22 @@ import { type KeyboardEvent, type ReactNode, useCallback, + useMemo, useRef, useState, } from "react"; import type { SessionListView, WorkspaceSession } from "../lib/workspace-data.ts"; -import { formatRelativeTime, type ProjectGroup, type SessionRow } from "../lib/session-tree.ts"; +import { + flattenProjectGroups, + formatRelativeTime, + moveIdInManualOrder, + type ProjectGroup, + type RailFilter, + type RailOrganization, + type RailSort, + type SessionRow, +} from "../lib/session-tree.ts"; import { composerStore } from "../features/composer/composer-store.ts"; import { createLiveSession } from "../features/session-runtime/live-create.ts"; import { @@ -88,17 +107,28 @@ function SessionRowItem({ index, nowMs, onAnnounce, + contextLabel, + manual, + canMoveUp, + canMoveDown, + onMove, }: { row: SessionRow; active: boolean; index: number; nowMs: number; onAnnounce: (message: string) => void; + contextLabel?: string; + manual?: boolean; + canMoveUp?: boolean; + canMoveDown?: boolean; + onMove?: (direction: -1 | 1) => void; }) { const navigate = useNavigate(); const snapshot = useDesktopRuntimeSnapshot(); const controller = desktopRuntime(); const { session } = row; + const pinned = useWorkspace((state) => state.pinnedSessionIds[session.id] === true); const stateLabel = describeSessionState(session); const ariaState = stateLabel !== "" ? stateLabel : (session.status ?? "idle"); const [menuOpen, setMenuOpen] = useState(false); @@ -263,7 +293,12 @@ function SessionRowItem({ type="button" > - + {session.title} {session.pendingApprovals > 0 && ( @@ -280,6 +315,12 @@ function SessionRowItem({ )} + {contextLabel !== undefined && ( + <> + {contextLabel} + + + )} {formatRelativeTime(session.updatedAt, nowMs)} {session.status !== null ? ( @@ -298,68 +339,122 @@ function SessionRowItem({ - {controller !== null && address !== null && ( - - - {pending === null ? ( - - - - - - {session.title} - - {!archived && - menuItem( - "rename", - "Rename", - + + + {error !== null && (

@@ -550,6 +645,12 @@ function ProjectHeaderRow({ shortcutHidden, onDismiss, onRestore, + pinned, + manual, + canMoveUp, + canMoveDown, + onMove, + onPin, view, }: { group: ProjectGroup; @@ -557,6 +658,12 @@ function ProjectHeaderRow({ shortcutHidden: boolean; onDismiss: () => void; onRestore: () => void; + pinned: boolean; + manual: boolean; + canMoveUp: boolean; + canMoveDown: boolean; + onMove: (direction: -1 | 1) => void; + onPin: () => void; view: SessionListView; }) { const navigate = useNavigate(); @@ -634,7 +741,7 @@ function ProjectHeaderRow({ allowCreate && !pending && (canCreate || configuredLocalProfiles.length > 0); const emptyCurrentProject = view === "current" && group.sessions.length === 0; const inventoryTruncated = group.host.sessionInventoryTruncated === true; - const showProjectMenu = emptyCurrentProject || (view === "archived" && shortcutHidden); + const showShortcutAction = emptyCurrentProject || (view === "archived" && shortcutHidden); const handleCreate = useCallback( async (targetAddress: NonNullable) => { @@ -688,16 +795,16 @@ function ProjectHeaderRow({ {group.host.kind === "local" && group.host.profileId !== undefined && group.host.profileId !== "default" && ( -

@@ -940,18 +1103,97 @@ function handleRailKeyDown(event: KeyboardEvent) { event.preventDefault(); } +const RAIL_FILTERS: ReadonlyArray<{ readonly value: RailFilter; readonly label: string }> = [ + { value: "all", label: "All" }, + { value: "attention", label: "Attention" }, + { value: "running", label: "Running" }, + { value: "unread", label: "Unread" }, + { value: "errors", label: "Errors" }, +]; + +function RailOptionsMenu({ + organization, + sort, +}: { + organization: RailOrganization; + sort: RailSort; +}) { + const [open, setOpen] = useState(false); + const option = (selected: boolean, label: string, onSelect: () => void) => ( + + ); + + return ( + + + + + + + + Organize sidebar + + {option(organization === "by-project", "By working folder", () => + workspaceStore.getState().setRailOrganization("by-project"), + )} + {option(organization === "flat", "In one list", () => + workspaceStore.getState().setRailOrganization("flat"), + )} +

+

Sort by

+ {option(sort === "priority", "Priority", () => + workspaceStore.getState().setRailSort("priority"), + )} + {option(sort === "updated", "Last updated", () => + workspaceStore.getState().setRailSort("updated"), + )} + {option(sort === "manual", "Manual order", () => + workspaceStore.getState().setRailSort("manual"), + )} + + + + + ); +} + export function Rail({ + allGroups, groups, hiddenEmptyProjectIds, nowMs, + pinnedSessionGroups, view, currentCount, archivedCount, attentionCount, }: { + allGroups: readonly ProjectGroup[]; groups: readonly ProjectGroup[]; hiddenEmptyProjectIds: ReadonlySet; nowMs: number; + pinnedSessionGroups: readonly ProjectGroup[]; view: SessionListView; currentCount: number; archivedCount: number; @@ -959,8 +1201,69 @@ export function Rail({ }) { const navigate = useNavigate(); const activeSessionId = useWorkspace((state) => state.activeSessionId); + const organization = useWorkspace((state) => state.railOrganization); + const sort = useWorkspace((state) => state.railSort); + const query = useWorkspace((state) => state.railQuery); + const filter = useWorkspace((state) => state.railFilter); + const pinnedProjectIds = useWorkspace((state) => state.pinnedProjectIds); + const pinnedSessionIds = useWorkspace((state) => state.pinnedSessionIds); + const projectManualOrder = useWorkspace((state) => state.projectManualOrder); + const sessionManualOrderByProjectId = useWorkspace( + (state) => state.sessionManualOrderByProjectId, + ); const [announcement, setAnnouncement] = useState(""); + const [projectLimits, setProjectLimits] = useState>({}); + const [flatLimit, setFlatLimit] = useState(40); const navRef = useRef(null); + const flatEntries = useMemo( + () => flattenProjectGroups(groups, sort, sessionManualOrderByProjectId["*"]), + [groups, sessionManualOrderByProjectId, sort], + ); + const pinnedSourceEntries = useMemo( + () => flattenProjectGroups(pinnedSessionGroups, sort, sessionManualOrderByProjectId["*"]), + [pinnedSessionGroups, sessionManualOrderByProjectId, sort], + ); + const pinnedEntries = useMemo(() => { + const seen = new Set(); + return pinnedSourceEntries.filter(({ row }) => { + if (pinnedSessionIds[row.session.id] !== true || seen.has(row.session.id)) return false; + seen.add(row.session.id); + return true; + }); + }, [pinnedSessionIds, pinnedSourceEntries]); + const pinnedGroups = useMemo( + () => allGroups.filter((group) => pinnedProjectIds[group.project.id] === true), + [allGroups, pinnedProjectIds], + ); + const matchCount = flatEntries.length; + + const moveProject = (projectId: string, direction: -1 | 1) => { + const visibleIds = groups.map((group) => group.project.id); + workspaceStore + .getState() + .setProjectManualOrder( + moveIdInManualOrder(projectManualOrder, visibleIds, projectId, direction), + ); + }; + + const moveSession = ( + projectId: string, + visibleIds: readonly string[], + sessionId: string, + direction: -1 | 1, + ) => { + workspaceStore + .getState() + .setSessionManualOrder( + projectId, + moveIdInManualOrder( + sessionManualOrderByProjectId[projectId] ?? [], + visibleIds, + sessionId, + direction, + ), + ); + }; const dismissProject = (group: ProjectGroup) => { const disclosures = [ @@ -991,11 +1294,52 @@ export function Rail({ tabIndex={-1} >
-
+

Sessions

- ⌘K + {matchCount} matches + +
+ +
+ {RAIL_FILTERS.map((item) => ( + + ))}
+
+ )} + {(pinnedEntries.length > 0 || pinnedGroups.length > 0) && ( +
+
+
+
+ {pinnedGroups.map((group) => ( + + ))} + {pinnedEntries.slice(0, 8).map(({ group, row }) => ( + + ))} +
+
+ )} + {organization === "flat" ? ( +
+
+
+
+ {flatEntries.slice(0, flatLimit).map(({ group, row }, index) => ( + 0} + contextLabel={group.project.name} + index={rowIndex++} + key={row.session.id} + manual={sort === "manual"} + nowMs={nowMs} + onAnnounce={setAnnouncement} + onMove={(direction) => + moveSession( + "*", + flatEntries.map((entry) => entry.row.session.id), + row.session.id, + direction, + ) + } + row={row} + /> + ))} +
+ {flatEntries.length > flatLimit && ( + )}
- ))} + ) : ( + groups.map((group, groupIndex) => { + const limit = projectLimits[group.project.id] ?? 6; + const visibleRows = group.sessions.slice(0, limit); + const sessionIds = group.sessions.map((row) => row.session.id); + return ( +
+ 0} + group={group} + manual={sort === "manual"} + onDismiss={() => dismissProject(group)} + onMove={(direction) => moveProject(group.project.id, direction)} + onPin={() => { + const pinned = pinnedProjectIds[group.project.id] === true; + workspaceStore.getState().setProjectPinned(group.project.id, !pinned); + setAnnouncement(`${group.project.name} ${pinned ? "unpinned" : "pinned"}.`); + }} + onRestore={() => { + workspaceStore.getState().setEmptyProjectDismissed(group.project.id, false); + setAnnouncement( + `Restored ${group.project.name} to Working folders on this T4 Code client.`, + ); + }} + pinned={pinnedProjectIds[group.project.id] === true} + shortcutHidden={hiddenEmptyProjectIds.has(group.project.id)} + view={view} + /> + {group.expanded && ( +
+ {visibleRows.map((row, index) => ( + 0} + index={rowIndex++} + key={row.session.id} + manual={sort === "manual"} + nowMs={nowMs} + onAnnounce={setAnnouncement} + onMove={(direction) => + moveSession(group.project.id, sessionIds, row.session.id, direction) + } + row={row} + /> + ))} + {group.sessions.length > limit && ( + + )} +
+ )} +
+ ); + }) + )} ); } diff --git a/apps/web/src/lib/session-tree.ts b/apps/web/src/lib/session-tree.ts index dcb4139..a3d70b6 100644 --- a/apps/web/src/lib/session-tree.ts +++ b/apps/web/src/lib/session-tree.ts @@ -17,6 +17,18 @@ export interface SessionRow { readonly unread: boolean; } +export type RailOrganization = "by-project" | "flat"; +export type RailSort = "priority" | "updated" | "manual"; +export type RailFilter = "all" | "attention" | "running" | "unread" | "errors"; + +export interface RailViewOptions { + readonly filter?: RailFilter; + readonly query?: string; + readonly sort?: RailSort; + readonly projectManualOrder?: readonly string[]; + readonly sessionManualOrderByProjectId?: Readonly>; +} + export interface ProjectGroup { readonly project: WorkspaceProject; readonly host: WorkspaceHost; @@ -28,13 +40,124 @@ export interface ProjectGroup { readonly pendingApprovals: number; } +function manualRank(order: readonly string[] | undefined, id: string): number { + const rank = order?.indexOf(id) ?? -1; + return rank === -1 ? Number.MAX_SAFE_INTEGER : rank; +} + +/** + * T4's visible Priority order is deliberately simple and user-facing: + * approval, input, running, unread completion, error, plan, then recency. + */ +export function sessionPriority(row: SessionRow): number { + if (row.session.pendingApprovals > 0 || row.session.status === "pendingApproval") return 6; + if (row.session.status === "awaitingInput") return 5; + if (row.session.status === "working" || row.session.status === "connecting") return 4; + if (row.unread) return 3; + if (row.session.status === "error") return 2; + if (row.session.status === "planReady") return 1; + return 0; +} + +function compareUpdated(left: WorkspaceSession, right: WorkspaceSession): number { + return ( + Date.parse(right.updatedAt) - Date.parse(left.updatedAt) || left.id.localeCompare(right.id) + ); +} + +function matchesFilter(row: SessionRow, filter: RailFilter): boolean { + if (filter === "all") return true; + if (filter === "attention") { + return row.session.pendingApprovals > 0 || row.session.status === "awaitingInput" || row.unread; + } + if (filter === "running") { + return row.session.status === "working" || row.session.status === "connecting"; + } + if (filter === "unread") return row.unread; + return row.session.status === "error"; +} + +export function sortSessionRows( + rows: readonly SessionRow[], + sort: RailSort, + manualOrder?: readonly string[], +): SessionRow[] { + return [...rows].sort((left, right) => { + if (sort === "manual") { + const manual = + manualRank(manualOrder, left.session.id) - manualRank(manualOrder, right.session.id); + if (manual !== 0) return manual; + } + if (sort === "priority") { + const priority = sessionPriority(right) - sessionPriority(left); + if (priority !== 0) return priority; + } + return compareUpdated(left.session, right.session); + }); +} + +export function flattenProjectGroups( + groups: readonly ProjectGroup[], + sort: RailSort, + manualOrder?: readonly string[], +): Array<{ readonly group: ProjectGroup; readonly row: SessionRow }> { + const entries = groups.flatMap((group) => group.sessions.map((row) => ({ group, row }))); + return entries.sort((left, right) => { + if (sort === "manual") { + const manual = + manualRank(manualOrder, left.row.session.id) - + manualRank(manualOrder, right.row.session.id); + if (manual !== 0) return manual; + } + if (sort === "priority") { + const priority = sessionPriority(right.row) - sessionPriority(left.row); + if (priority !== 0) return priority; + } + return compareUpdated(left.row.session, right.row.session); + }); +} + +export function moveIdInManualOrder( + storedOrder: readonly string[], + visibleIds: readonly string[], + id: string, + direction: -1 | 1, +): string[] { + const visible = new Set(visibleIds); + const seen = new Set(); + const complete: string[] = []; + const hidden: string[] = []; + for (const entry of storedOrder) { + if (seen.has(entry)) continue; + seen.add(entry); + if (visible.has(entry)) complete.push(entry); + else hidden.push(entry); + } + for (const visibleId of visibleIds) { + if (seen.has(visibleId)) continue; + seen.add(visibleId); + complete.push(visibleId); + } + const index = complete.indexOf(id); + const target = index + direction; + if (index < 0 || target < 0 || target >= complete.length) return [...complete, ...hidden]; + const next = [...complete]; + [next[index], next[target]] = [next[target]!, next[index]!]; + return [...next, ...hidden]; +} + export function buildProjectGroups( data: WorkspaceData, projectExpandedById: Readonly>, lastVisitedAtBySessionId: Readonly>, view: SessionListView = "current", dismissedEmptyProjectIds: Readonly> = {}, + options: RailViewOptions = {}, ): ProjectGroup[] { + const filter = options.filter ?? "all"; + const query = options.query?.trim().toLocaleLowerCase() ?? ""; + const sort = options.sort ?? "updated"; + const filtering = filter !== "all" || query !== ""; const groups: ProjectGroup[] = []; for (const project of data.projects) { const host = data.hosts.find((entry) => entry.id === project.hostId); @@ -53,14 +176,21 @@ export function buildProjectGroups( lastVisitedAtBySessionId[session.id], session.latestTurnCompletedAt, ), - })); + })) + .filter((row) => { + if (!matchesFilter(row, filter)) return false; + if (query === "") return true; + return `${row.session.title} ${row.session.model} ${project.name} ${host.name}` + .toLocaleLowerCase() + .includes(query); + }); // Current is also the project-management view. Keep known projects // reachable after their final current session is archived so the user can // immediately create another session in that working folder, unless they // explicitly removed that empty shortcut. A new current session always // makes the project visible again. Archived is session-only and never // applies the Current-tab dismissal. - if (sessions.length === 0 && view === "archived") continue; + if (sessions.length === 0 && (view === "archived" || filtering)) continue; if ( sessions.length === 0 && view === "current" && @@ -73,13 +203,39 @@ export function buildProjectGroups( project, host, expanded: projectExpandedById[project.id] ?? true, - sessions, + sessions: sortSessionRows( + sessions, + sort, + options.sessionManualOrderByProjectId?.[project.id], + ), groupStatus: resolveHighestPriorityStatus(sessions.map((row) => row.session.status)), unreadCount: sessions.filter((row) => row.unread).length, pendingApprovals: sessions.reduce((sum, row) => sum + row.session.pendingApprovals, 0), }); } - return groups; + return groups.sort((left, right) => { + if (sort === "manual") { + const manual = + manualRank(options.projectManualOrder, left.project.id) - + manualRank(options.projectManualOrder, right.project.id); + if (manual !== 0) return manual; + } + if (sort === "priority") { + const priority = + Math.max(0, ...right.sessions.map(sessionPriority)) - + Math.max(0, ...left.sessions.map(sessionPriority)); + if (priority !== 0) return priority; + } + const leftUpdated = Math.max( + 0, + ...left.sessions.map((row) => Date.parse(row.session.updatedAt)), + ); + const rightUpdated = Math.max( + 0, + ...right.sessions.map((row) => Date.parse(row.session.updatedAt)), + ); + return rightUpdated - leftUpdated || left.project.name.localeCompare(right.project.name); + }); } /** Sessions reachable by Cmd/Ctrl+1..9: rail order, expanded projects only. */ diff --git a/apps/web/src/state/workspace-store.ts b/apps/web/src/state/workspace-store.ts index 59af94d..a57b6c9 100644 --- a/apps/web/src/state/workspace-store.ts +++ b/apps/web/src/state/workspace-store.ts @@ -8,10 +8,11 @@ import { clampWidth } from "@t4-code/ui"; import { createStore, type StoreApi } from "zustand/vanilla"; +import type { RailFilter, RailOrganization, RailSort } from "../lib/session-tree.ts"; import type { SessionListView } from "../lib/workspace-data.ts"; import type { WorkspacePersistence } from "./persistence.ts"; -export const WORKSPACE_STATE_VERSION = 2; +export const WORKSPACE_STATE_VERSION = 3; export const WORKSPACE_STORAGE_KEY = "omp:workspace:v1"; export const RAIL_WIDTH = { minWidth: 208, maxWidth: 400, defaultWidth: 256 } as const; @@ -69,6 +70,12 @@ interface PersistedWorkspaceState { readonly railWidth: number; readonly railCollapsed: boolean; readonly sessionListView?: SessionListView; + readonly railOrganization?: RailOrganization; + readonly railSort?: RailSort; + readonly pinnedProjectIds?: Record; + readonly pinnedSessionIds?: Record; + readonly projectManualOrder?: readonly string[]; + readonly sessionManualOrderByProjectId?: Readonly>; readonly activeSessionId: string | null; readonly projectExpandedById: Record; /** Empty Current-tab project headers the user explicitly removed. */ @@ -84,6 +91,15 @@ export interface WorkspaceState { readonly railWidth: number; readonly railCollapsed: boolean; readonly sessionListView: SessionListView; + readonly railOrganization: RailOrganization; + readonly railSort: RailSort; + /** Search and status filters are intentionally ephemeral so a restart never hides work. */ + readonly railQuery: string; + readonly railFilter: RailFilter; + readonly pinnedProjectIds: Record; + readonly pinnedSessionIds: Record; + readonly projectManualOrder: readonly string[]; + readonly sessionManualOrderByProjectId: Readonly>; /** Narrow-width overlay rail; ephemeral, never persisted. */ readonly railOverlayOpen: boolean; /** Command palette visibility; ephemeral, never persisted. */ @@ -105,6 +121,14 @@ export interface WorkspaceActions { setRailWidth(width: number): void; setRailCollapsed(collapsed: boolean): void; setSessionListView(view: SessionListView): void; + setRailOrganization(organization: RailOrganization): void; + setRailSort(sort: RailSort): void; + setRailQuery(query: string): void; + setRailFilter(filter: RailFilter): void; + setProjectPinned(projectId: string, pinned: boolean): void; + setSessionPinned(sessionId: string, pinned: boolean): void; + setProjectManualOrder(projectIds: readonly string[]): void; + setSessionManualOrder(projectId: string, sessionIds: readonly string[]): void; setRailOverlayOpen(open: boolean): void; setPaletteOpen(open: boolean): void; setFocusMode(enabled: boolean): void; @@ -123,10 +147,7 @@ export interface WorkspaceActions { setPaneOpen(sessionId: string, open: boolean): void; setPaneWidth(sessionId: string, width: number): void; setTerminalDrawerOpen(sessionId: string, open: boolean): void; - setSessionPreview( - sessionId: string, - selection: SessionPreviewSelection, - ): void; + setSessionPreview(sessionId: string, selection: SessionPreviewSelection): void; setSessionPreviewScale(sessionId: string, scale: PreviewScaleMode): void; } @@ -138,6 +159,14 @@ const INITIAL_STATE: WorkspaceState = { railWidth: RAIL_WIDTH.defaultWidth, railCollapsed: false, sessionListView: "current", + railOrganization: "by-project", + railSort: "priority", + railQuery: "", + railFilter: "all", + pinnedProjectIds: {}, + pinnedSessionIds: {}, + projectManualOrder: [], + sessionManualOrderByProjectId: {}, railOverlayOpen: false, paletteOpen: false, focusMode: false, @@ -167,6 +196,28 @@ function sanitizeTrueRecord(value: unknown): Record { return result; } +function sanitizeIdList(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return [ + ...new Set( + value.filter( + (entry): entry is string => + typeof entry === "string" && entry.length > 0 && entry.length <= 1_024, + ), + ), + ].slice(0, 10_000); +} + +function sanitizeNestedIdLists(value: unknown): Record { + if (typeof value !== "object" || value === null) return {}; + const result: Record = {}; + for (const [key, entry] of Object.entries(value).slice(0, 1_000)) { + if (key.length === 0 || key.length > 1_024) continue; + result[key] = sanitizeIdList(entry); + } + return result; +} + function sanitizeTimestampRecord(value: unknown): Record { if (typeof value !== "object" || value === null) return {}; const result: Record = {}; @@ -238,7 +289,8 @@ function sanitizeSessionView(value: unknown): SessionViewState | null { export function parsePersistedWorkspace(raw: unknown): WorkspaceState | null { if (typeof raw !== "object" || raw === null) return null; const parsed = raw as Partial; - if (parsed.version !== 1 && parsed.version !== WORKSPACE_STATE_VERSION) return null; + if (parsed.version !== 1 && parsed.version !== 2 && parsed.version !== WORKSPACE_STATE_VERSION) + return null; const sessionViewById: Record = {}; if (typeof parsed.sessionViewById === "object" && parsed.sessionViewById !== null) { @@ -260,6 +312,13 @@ export function parsePersistedWorkspace(raw: unknown): WorkspaceState | null { : RAIL_WIDTH.defaultWidth, railCollapsed: parsed.railCollapsed === true, sessionListView: parsed.sessionListView === "archived" ? "archived" : "current", + railOrganization: parsed.railOrganization === "flat" ? "flat" : "by-project", + railSort: + parsed.railSort === "updated" || parsed.railSort === "manual" ? parsed.railSort : "priority", + pinnedProjectIds: sanitizeTrueRecord(parsed.pinnedProjectIds), + pinnedSessionIds: sanitizeTrueRecord(parsed.pinnedSessionIds), + projectManualOrder: sanitizeIdList(parsed.projectManualOrder), + sessionManualOrderByProjectId: sanitizeNestedIdLists(parsed.sessionManualOrderByProjectId), activeSessionId: typeof parsed.activeSessionId === "string" ? parsed.activeSessionId : null, projectExpandedById: sanitizeBooleanRecord(parsed.projectExpandedById), dismissedEmptyProjectIds: sanitizeTrueRecord(parsed.dismissedEmptyProjectIds), @@ -278,6 +337,12 @@ export function toPersistedWorkspace(state: WorkspaceState): PersistedWorkspaceS railWidth: state.railWidth, railCollapsed: state.railCollapsed, sessionListView: state.sessionListView, + railOrganization: state.railOrganization, + railSort: state.railSort, + pinnedProjectIds: state.pinnedProjectIds, + pinnedSessionIds: state.pinnedSessionIds, + projectManualOrder: state.projectManualOrder, + sessionManualOrderByProjectId: state.sessionManualOrderByProjectId, activeSessionId: state.activeSessionId, projectExpandedById: state.projectExpandedById, dismissedEmptyProjectIds: state.dismissedEmptyProjectIds, @@ -362,6 +427,33 @@ export function createWorkspaceStore(options: CreateWorkspaceStoreOptions): Work setRailWidth: (width) => set({ railWidth: clampWidth(width, RAIL_WIDTH) }), setRailCollapsed: (collapsed) => set({ railCollapsed: collapsed }), setSessionListView: (view) => set({ sessionListView: view }), + setRailOrganization: (railOrganization) => set({ railOrganization }), + setRailSort: (railSort) => set({ railSort }), + setRailQuery: (railQuery) => set({ railQuery: railQuery.slice(0, 512) }), + setRailFilter: (railFilter) => set({ railFilter }), + setProjectPinned: (projectId, pinned) => + set((state) => { + const pinnedProjectIds = { ...state.pinnedProjectIds }; + if (pinned) pinnedProjectIds[projectId] = true; + else delete pinnedProjectIds[projectId]; + return { pinnedProjectIds }; + }), + setSessionPinned: (sessionId, pinned) => + set((state) => { + const pinnedSessionIds = { ...state.pinnedSessionIds }; + if (pinned) pinnedSessionIds[sessionId] = true; + else delete pinnedSessionIds[sessionId]; + return { pinnedSessionIds }; + }), + setProjectManualOrder: (projectManualOrder) => + set({ projectManualOrder: sanitizeIdList(projectManualOrder) }), + setSessionManualOrder: (projectId, sessionIds) => + set((state) => ({ + sessionManualOrderByProjectId: { + ...state.sessionManualOrderByProjectId, + [projectId]: sanitizeIdList(sessionIds), + }, + })), setRailOverlayOpen: (open) => set({ railOverlayOpen: open }), setPaletteOpen: (open) => set({ paletteOpen: open }), setFocusMode: (enabled) => set({ focusMode: enabled }), diff --git a/apps/web/test/session-tree.test.ts b/apps/web/test/session-tree.test.ts index df7f665..b279905 100644 --- a/apps/web/test/session-tree.test.ts +++ b/apps/web/test/session-tree.test.ts @@ -3,8 +3,11 @@ import { describe, expect, it } from "vite-plus/test"; import { SHELL_FIXTURE } from "../src/fixture/data.ts"; import { buildProjectGroups, + flattenProjectGroups, formatRelativeTime, listVisibleSessionIds, + moveIdInManualOrder, + sessionPriority, } from "../src/lib/session-tree.ts"; describe("fixture invariants", () => { @@ -45,6 +48,103 @@ describe("fixture invariants", () => { }); describe("buildProjectGroups", () => { + it("filters by title, project, runtime state, unread state, and errors", () => { + const byText = buildProjectGroups( + SHELL_FIXTURE, + {}, + SHELL_FIXTURE.seedLastVisitedAt, + "current", + {}, + { query: "pagination" }, + ); + expect(byText.map((group) => group.project.id)).toEqual(["proj-notes"]); + expect(byText[0]?.sessions.map((row) => row.session.id)).toEqual(["sess-pagination"]); + + const running = buildProjectGroups(SHELL_FIXTURE, {}, {}, "current", {}, { filter: "running" }); + expect(running.flatMap((group) => group.sessions.map((row) => row.session.id))).toEqual([ + "sess-stream", + ]); + + const unread = buildProjectGroups( + SHELL_FIXTURE, + {}, + SHELL_FIXTURE.seedLastVisitedAt, + "current", + {}, + { filter: "unread" }, + ); + expect(unread.flatMap((group) => group.sessions.map((row) => row.session.id))).toEqual([ + "sess-motion", + ]); + + const errors = buildProjectGroups(SHELL_FIXTURE, {}, {}, "current", {}, { filter: "errors" }); + expect(errors.flatMap((group) => group.sessions.map((row) => row.session.id))).toEqual([ + "sess-resize", + ]); + }); + + it("sorts priority by the user-facing attention order", () => { + const groups = buildProjectGroups( + SHELL_FIXTURE, + {}, + SHELL_FIXTURE.seedLastVisitedAt, + "current", + {}, + { sort: "priority" }, + ); + expect(groups.map((group) => group.project.id)).toEqual(["proj-omp", "proj-t4", "proj-notes"]); + expect(groups[0]?.sessions.map((row) => row.session.id)).toEqual([ + "sess-settings", + "sess-stream", + "sess-bundle", + ]); + expect(groups[1]?.sessions.slice(0, 3).map((row) => row.session.id)).toEqual([ + "sess-fixtures", + "sess-motion", + "sess-resize", + ]); + expect(sessionPriority(groups[0]!.sessions[0]!)).toBe(6); + }); + + it("honors stable manual project, grouped-session, and flat-session order", () => { + const groups = buildProjectGroups( + SHELL_FIXTURE, + {}, + {}, + "current", + {}, + { + sort: "manual", + projectManualOrder: ["proj-notes", "proj-t4", "proj-omp"], + sessionManualOrderByProjectId: { + "proj-t4": ["sess-notes", "sess-resize", "sess-motion", "sess-fixtures"], + }, + }, + ); + expect(groups.map((group) => group.project.id)).toEqual(["proj-notes", "proj-t4", "proj-omp"]); + expect(groups[1]?.sessions.map((row) => row.session.id)).toEqual([ + "sess-notes", + "sess-resize", + "sess-motion", + "sess-fixtures", + ]); + expect( + flattenProjectGroups(groups, "manual", ["sess-stream", "sess-theme"]) + .slice(0, 2) + .map((entry) => entry.row.session.id), + ).toEqual(["sess-stream", "sess-theme"]); + }); + + it("moves visible manual-order entries without losing hidden entries", () => { + expect(moveIdInManualOrder(["hidden", "b"], ["a", "b", "c"], "b", -1)).toEqual([ + "b", + "a", + "c", + "hidden", + ]); + expect(moveIdInManualOrder(["a", "b"], ["a", "b"], "a", -1)).toEqual(["a", "b"]); + }); + it("dismisses an empty Current header without hiding its archived sessions", () => { const project = SHELL_FIXTURE.projects[0]; const session = SHELL_FIXTURE.sessions.find((entry) => entry.projectId === project?.id); @@ -127,13 +227,9 @@ describe("buildProjectGroups", () => { .filter((session) => session.projectId === first.id || session.projectId === second.id) .map((session) => ({ ...session, archivedAt: "2026-07-12T12:00:00Z" })); - const groups = buildProjectGroups( - { ...SHELL_FIXTURE, projects, sessions }, - {}, - {}, - "current", - { [first.id]: true }, - ); + const groups = buildProjectGroups({ ...SHELL_FIXTURE, projects, sessions }, {}, {}, "current", { + [first.id]: true, + }); expect(groups.map((group) => group.project.id)).toEqual([second.id]); }); diff --git a/apps/web/test/workspace-store.test.ts b/apps/web/test/workspace-store.test.ts index ffeec84..e9407a1 100644 --- a/apps/web/test/workspace-store.test.ts +++ b/apps/web/test/workspace-store.test.ts @@ -149,11 +149,13 @@ describe("visited and unread", () => { it("tracks the latest seen attention outcome per session", () => { const { store } = makeStore(); - expect(isAttentionOutcomeSeen(store.getState().lastSeenAttentionOutcomeBySessionKey, "A", "one")) - .toBe(false); + expect( + isAttentionOutcomeSeen(store.getState().lastSeenAttentionOutcomeBySessionKey, "A", "one"), + ).toBe(false); store.getState().markAttentionOutcomeSeen("A", "one"); - expect(isAttentionOutcomeSeen(store.getState().lastSeenAttentionOutcomeBySessionKey, "A", "one")) - .toBe(true); + expect( + isAttentionOutcomeSeen(store.getState().lastSeenAttentionOutcomeBySessionKey, "A", "one"), + ).toBe(true); store.getState().markAttentionOutcomeSeen("A", "two"); expect(store.getState().lastSeenAttentionOutcomeBySessionKey).toEqual({ A: "two" }); }); @@ -167,6 +169,14 @@ describe("persistence", () => { first.getState().setSessionDraft("A", "resume me"); first.getState().setRailWidth(300); first.getState().setTheme("dark"); + first.getState().setRailOrganization("flat"); + first.getState().setRailSort("manual"); + first.getState().setProjectPinned("project-a", true); + first.getState().setSessionPinned("A", true); + first.getState().setProjectManualOrder(["project-b", "project-a"]); + first.getState().setSessionManualOrder("project-a", ["B", "A"]); + first.getState().setRailQuery("hidden on restart"); + first.getState().setRailFilter("errors"); first.getState().setEmptyProjectDismissed("host/project", true); first.getState().setSessionPreview("A", { previewId: "preview-a", @@ -190,6 +200,14 @@ describe("persistence", () => { expect(selectSessionView(state, "A").previewScale).toBe("actual"); expect(state.railWidth).toBe(300); expect(state.theme).toBe("dark"); + expect(state.railOrganization).toBe("flat"); + expect(state.railSort).toBe("manual"); + expect(state.pinnedProjectIds).toEqual({ "project-a": true }); + expect(state.pinnedSessionIds).toEqual({ A: true }); + expect(state.projectManualOrder).toEqual(["project-b", "project-a"]); + expect(state.sessionManualOrderByProjectId).toEqual({ "project-a": ["B", "A"] }); + expect(state.railQuery).toBe(""); + expect(state.railFilter).toBe("all"); expect(state.dismissedEmptyProjectIds).toEqual({ "host/project": true }); expect(state.lastSeenAttentionOutcomeBySessionKey).toEqual({ A: "outcome-1" }); expect(state.paletteOpen).toBe(false); @@ -229,6 +247,12 @@ describe("persistence", () => { dismissedEmptyProjectIds: {}, lastVisitedAtBySessionId: { A: "2026-07-11T10:00:00Z" }, lastSeenAttentionOutcomeBySessionKey: {}, + railOrganization: "by-project", + railSort: "priority", + pinnedProjectIds: {}, + pinnedSessionIds: {}, + projectManualOrder: [], + sessionManualOrderByProjectId: {}, }); expect(parsed?.sessionViewById.A).toMatchObject({ scrollTop: 42, @@ -278,6 +302,12 @@ describe("persistence", () => { activeSessionId: 42, projectExpandedById: { good: true, bad: "nope" }, dismissedEmptyProjectIds: { good: true, falseEntry: false, bad: "yes" }, + railOrganization: "tiles", + railSort: "random", + pinnedProjectIds: { good: true, bad: false }, + pinnedSessionIds: { session: true, bad: "yes" }, + projectManualOrder: ["project", "project", 42], + sessionManualOrderByProjectId: { project: ["session", "session", null] }, lastVisitedAtBySessionId: { good: "2026-07-11T10:00:00Z", bad: "not a date" }, lastSeenAttentionOutcomeBySessionKey: { good: "outcome-1", @@ -303,6 +333,12 @@ describe("persistence", () => { expect(parsed?.activeSessionId).toBeNull(); expect(parsed?.projectExpandedById).toEqual({ good: true }); expect(parsed?.dismissedEmptyProjectIds).toEqual({ good: true }); + expect(parsed?.railOrganization).toBe("by-project"); + expect(parsed?.railSort).toBe("priority"); + expect(parsed?.pinnedProjectIds).toEqual({ good: true }); + expect(parsed?.pinnedSessionIds).toEqual({ session: true }); + expect(parsed?.projectManualOrder).toEqual(["project"]); + expect(parsed?.sessionManualOrderByProjectId).toEqual({ project: ["session"] }); expect(parsed?.lastVisitedAtBySessionId).toEqual({ good: "2026-07-11T10:00:00Z" }); expect(parsed?.lastSeenAttentionOutcomeBySessionKey).toEqual({ good: "outcome-1" }); const view = parsed?.sessionViewById["good"]; @@ -320,10 +356,14 @@ describe("persistence", () => { store.getState().setPaletteOpen(true); store.getState().setRailOverlayOpen(true); store.getState().setFocusMode(true); + store.getState().setRailQuery("temporary"); + store.getState().setRailFilter("running"); const snapshot = toPersistedWorkspace(store.getState()) as unknown as Record; expect("paletteOpen" in snapshot).toBe(false); expect("railOverlayOpen" in snapshot).toBe(false); expect("focusMode" in snapshot).toBe(false); + expect("railQuery" in snapshot).toBe(false); + expect("railFilter" in snapshot).toBe(false); }); it("can clear an empty-project dismissal without disturbing other projects", () => { @@ -336,6 +376,19 @@ describe("persistence", () => { "same-name/project-b": true, }); }); + + it("pins and unpins shortcuts without touching runtime session state", () => { + const { store } = makeStore(); + store.getState().setProjectPinned("project-a", true); + store.getState().setSessionPinned("session-a", true); + expect(store.getState().pinnedProjectIds).toEqual({ "project-a": true }); + expect(store.getState().pinnedSessionIds).toEqual({ "session-a": true }); + + store.getState().setProjectPinned("project-a", false); + store.getState().setSessionPinned("session-a", false); + expect(store.getState().pinnedProjectIds).toEqual({}); + expect(store.getState().pinnedSessionIds).toEqual({}); + }); }); describe("layout clamping", () => { diff --git a/e2e/remote-app.spec.ts b/e2e/remote-app.spec.ts index f0a6af5..d42081d 100644 --- a/e2e/remote-app.spec.ts +++ b/e2e/remote-app.spec.ts @@ -748,7 +748,7 @@ test("settles a typed incompatible desktop inspection and recovers without a sta control.mode = "resolve"; control.resolvePending?.({ definition: "current", service: "running", diagnostics: "" }); }); - await expect(page.getByText("Running", { exact: true })).toBeVisible(); + await expect(page.getByRole("main").getByText("Running", { exact: true })).toBeVisible(); await page.clock.fastForward(60_000); expect(await inspectCalls()).toBe(2); }); @@ -1329,7 +1329,9 @@ test("manages a session from a phone and converges another live client", async ( const rail = page.getByRole("dialog", { name: "Working folders and sessions" }); await expect(rail).toBeVisible(); await expect(rail.getByRole("heading", { name: "Sessions", exact: true })).toBeVisible(); - await expect(rail.getByRole("button", { name: /Attention/ })).toBeVisible(); + await expect( + rail.getByRole("button", { name: "Open attention inbox", exact: true }), + ).toBeVisible(); await expect(rail.getByRole("button", { name: "Current · 1", exact: true })).toBeVisible(); await expect(rail.getByRole("button", { name: "Archived · 0", exact: true })).toBeVisible(); diff --git a/provenance/t3code/imports/f1-shell-20260711.json b/provenance/t3code/imports/f1-shell-20260711.json index 178f163..2c5ce14 100644 --- a/provenance/t3code/imports/f1-shell-20260711.json +++ b/provenance/t3code/imports/f1-shell-20260711.json @@ -9,7 +9,7 @@ "sourceBlobSha": "4a97f0542b4c95e96eae835a8d01db56e7156f1a;70d163306cca5b32d32319359c8ecc9a68c3dbf8", "targetPath": "apps/web/src/state/workspace-store.ts", "classification": "adapted", - "checksum": "sha256:c5c428e175cf309d379226ec3b0a1516f75b7d748f2c9a7992563c2c76f26a23" + "checksum": "sha256:916414ec13bdb924bec3e1e2adab264c2a7a5ac1d5485635bcf4215bada6d368" }, { "sourcePath": "apps/web/src/hooks/useTheme.ts", @@ -51,7 +51,7 @@ "sourceBlobSha": "4e7614ed55161f50f756184251c80cf9dfb16ea2", "targetPath": "apps/web/src/lib/session-tree.ts", "classification": "reference-only", - "checksum": "sha256:7f78fd2e887eb6d8d1b0c2961b4693ca56a97464703a1ee4e3ca936ae6248246" + "checksum": "sha256:ed0aba1bb3702920df49d893638dab948fa3ff4fc254cb8cef8e6e2dee261095" } ] }