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",
- ,
- renameSupport,
- )}
- {!archived &&
- menuItem(
- "terminate",
- "Terminate runtime",
- ,
- terminateSupport,
- )}
- {archived
- ? menuItem(
- "restore",
- "Restore",
- ,
- restoreSupport,
- )
- : menuItem(
- "archive",
- "Archive",
- ,
- archiveSupport,
+
+
+ {pending === null ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ {session.title}
+
+
+ {manual && onMove !== undefined && (
+ <>
+
+ ,
- deleteSupport,
+ onClick={() => {
+ if (!canMoveDown) return;
+ onMove(1);
+ setMenuOpen(false);
+ }}
+ type="button"
+ >
+
+ Move down
+
+ >
+ )}
+ {!archived &&
+ menuItem(
+ "rename",
+ "Rename",
+ ,
+ renameSupport,
)}
- {workingReason !== null && (
-
- {workingReason}
-
+ {!archived &&
+ menuItem(
+ "terminate",
+ "Terminate runtime",
+ ,
+ terminateSupport,
)}
-
-
-
-
- )}
+ {archived
+ ? menuItem(
+ "restore",
+ "Restore",
+ ,
+ restoreSupport,
+ )
+ : menuItem(
+ "archive",
+ "Archive",
+ ,
+ archiveSupport,
+ )}
+ {menuItem(
+ "delete",
+ "Permanently delete",
+ ,
+ deleteSupport,
+ )}
+ {workingReason !== null && (
+
+ {workingReason}
+
+ )}
+
+
+
+
{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" && (
-
+
)}
{!group.expanded && group.unreadCount > 0 && (
-
+
)}
{!group.expanded && group.groupStatus !== null && (
-
+
)}
{group.sessions.length}
@@ -850,21 +957,76 @@ function ProjectHeaderRow({
) : null}
- {showProjectMenu && (
-
-
-
-
-
-
-
-
- {group.project.name}
-
- {emptyCurrentProject ? (
+
+
+
+
+
+
+
+
+ {group.project.name}
+
+
+ {manual && (
+ <>
+
+
+ >
+ )}
+ {showShortcutAction &&
+ (emptyCurrentProject ? (
- )}
-
-
-
-
- )}
+ ))}
+
+
+
+
{error !== null && (
@@ -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) => (
+
+ ))}