Skip to content

Commit 50cc53b

Browse files
authored
🤖 Sort workspaces by recency (last stream start) (#205)
Workspaces now sort by most recent user message. Makes context-switching more intuitive—recently active work surfaces first. **Implementation:** - Tracks last user message timestamp (not assistant) to avoid reordering during concurrent streams - Computed synchronously in ProjectSidebar memo (no layout shift on first render) - Uses persisted message timestamps (survives restarts, correct during replay) **Architecture:** - No separate hook needed - timestamps come directly from WorkspaceState - Simpler than initial approach (net -47 LoC vs original branch start) - Single source of truth: message metadata _Generated with `cmux`_
1 parent 21cfaf4 commit 50cc53b

File tree

4 files changed

+180
-105
lines changed

4 files changed

+180
-105
lines changed

src/App.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ function AppInner() {
168168
});
169169

170170
// Use workspace aggregators hook for message state
171-
const { getWorkspaceState, getAggregator, workspaceStates } =
171+
const { getWorkspaceState, getAggregator, workspaceStates, workspaceRecency } =
172172
useWorkspaceAggregators(workspaceMetadata);
173173

174174
// Track unread message status for all workspaces
@@ -554,6 +554,7 @@ function AppInner() {
554554
onToggleCollapsed={() => setSidebarCollapsed((prev) => !prev)}
555555
onGetSecrets={handleGetSecrets}
556556
onUpdateSecrets={handleUpdateSecrets}
557+
workspaceRecency={workspaceRecency}
557558
/>
558559
<MainContent>
559560
<ContentArea>

src/components/LeftSidebar.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ interface LeftSidebarProps {
4343
onToggleCollapsed: () => void;
4444
onGetSecrets: (projectPath: string) => Promise<Secret[]>;
4545
onUpdateSecrets: (projectPath: string, secrets: Secret[]) => Promise<void>;
46+
workspaceRecency: Record<string, number>;
4647
}
4748

4849
export function LeftSidebar(props: LeftSidebarProps) {

src/components/ProjectSidebar.tsx

Lines changed: 124 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import React, { useState, useEffect, useCallback, useRef } from "react";
1+
import React, { useState, useEffect, useCallback, useRef, useMemo } from "react";
22
import { createPortal } from "react-dom";
33
import styled from "@emotion/styled";
44
import { css } from "@emotion/react";
5-
import type { ProjectConfig } from "@/config";
5+
import type { ProjectConfig, Workspace } from "@/config";
66
import type { WorkspaceMetadata } from "@/types/workspace";
77
import { useGitStatus } from "@/contexts/GitStatusContext";
88
import { usePersistedState } from "@/hooks/usePersistedState";
@@ -585,6 +585,7 @@ interface ProjectSidebarProps {
585585
onToggleCollapsed: () => void;
586586
onGetSecrets: (projectPath: string) => Promise<Secret[]>;
587587
onUpdateSecrets: (projectPath: string, secrets: Secret[]) => Promise<void>;
588+
workspaceRecency: Record<string, number>;
588589
}
589590

590591
const ProjectSidebar: React.FC<ProjectSidebarProps> = ({
@@ -604,10 +605,33 @@ const ProjectSidebar: React.FC<ProjectSidebarProps> = ({
604605
onToggleCollapsed,
605606
onGetSecrets,
606607
onUpdateSecrets,
608+
workspaceRecency,
607609
}) => {
608610
// Subscribe to git status updates (causes this component to re-render every 10s)
609611
const gitStatus = useGitStatus();
610612

613+
// Sort workspaces by last user message (most recent first)
614+
// workspaceRecency only updates when timestamps actually change (stable reference optimization)
615+
const sortedWorkspacesByProject = useMemo(() => {
616+
const result = new Map<string, Workspace[]>();
617+
for (const [projectPath, config] of projects) {
618+
result.set(
619+
projectPath,
620+
config.workspaces.slice().sort((a, b) => {
621+
const aMeta = workspaceMetadata.get(a.path);
622+
const bMeta = workspaceMetadata.get(b.path);
623+
if (!aMeta || !bMeta) return 0;
624+
625+
// Get timestamp of most recent user message (0 if never used)
626+
const aTimestamp = workspaceRecency[aMeta.id] ?? 0;
627+
const bTimestamp = workspaceRecency[bMeta.id] ?? 0;
628+
return bTimestamp - aTimestamp;
629+
})
630+
);
631+
}
632+
return result;
633+
}, [projects, workspaceMetadata, workspaceRecency]);
634+
611635
// Store as array in localStorage, convert to Set for usage
612636
const [expandedProjectsArray, setExpandedProjectsArray] = usePersistedState<string[]>(
613637
"expandedProjects",
@@ -964,109 +988,112 @@ const ProjectSidebar: React.FC<ProjectSidebarProps> = ({
964988
` (${formatKeybind(KEYBINDS.NEW_WORKSPACE)})`}
965989
</AddWorkspaceBtn>
966990
</WorkspaceHeader>
967-
{config.workspaces.map((workspace) => {
968-
const metadata = workspaceMetadata.get(workspace.path);
969-
if (!metadata) return null;
970-
971-
const workspaceId = metadata.id;
972-
const displayName = getWorkspaceDisplayName(workspace.path);
973-
const workspaceState = getWorkspaceState(workspaceId);
974-
const isStreaming = workspaceState.canInterrupt;
975-
// const streamingModel = workspaceState.currentModel; // Unused
976-
const isUnread = unreadStatus.get(workspaceId) ?? false;
977-
const isEditing = editingWorkspaceId === workspaceId;
978-
const isSelected = selectedWorkspace?.workspacePath === workspace.path;
979-
980-
return (
981-
<React.Fragment key={workspace.path}>
982-
<WorkspaceItem
983-
selected={isSelected}
984-
onClick={() =>
985-
onSelectWorkspace({
986-
projectPath,
987-
projectName,
988-
workspacePath: workspace.path,
989-
workspaceId,
990-
})
991-
}
992-
onKeyDown={(e) => {
993-
if (e.key === "Enter" || e.key === " ") {
994-
e.preventDefault();
991+
{(sortedWorkspacesByProject.get(projectPath) ?? config.workspaces).map(
992+
(workspace) => {
993+
const metadata = workspaceMetadata.get(workspace.path);
994+
if (!metadata) return null;
995+
996+
const workspaceId = metadata.id;
997+
const displayName = getWorkspaceDisplayName(workspace.path);
998+
const workspaceState = getWorkspaceState(workspaceId);
999+
const isStreaming = workspaceState.canInterrupt;
1000+
// const streamingModel = workspaceState.currentModel; // Unused
1001+
const isUnread = unreadStatus.get(workspaceId) ?? false;
1002+
const isEditing = editingWorkspaceId === workspaceId;
1003+
const isSelected =
1004+
selectedWorkspace?.workspacePath === workspace.path;
1005+
1006+
return (
1007+
<React.Fragment key={workspace.path}>
1008+
<WorkspaceItem
1009+
selected={isSelected}
1010+
onClick={() =>
9951011
onSelectWorkspace({
9961012
projectPath,
9971013
projectName,
9981014
workspacePath: workspace.path,
9991015
workspaceId,
1000-
});
1016+
})
10011017
}
1002-
}}
1003-
role="button"
1004-
tabIndex={0}
1005-
aria-current={isSelected ? "true" : undefined}
1006-
data-workspace-path={workspace.path}
1007-
data-workspace-id={workspaceId}
1008-
>
1009-
<TooltipWrapper inline>
1010-
<WorkspaceRemoveBtn
1011-
onClick={(e) => {
1012-
e.stopPropagation();
1013-
void handleRemoveWorkspace(workspaceId, e.currentTarget);
1014-
}}
1015-
aria-label={`Remove workspace ${displayName}`}
1016-
data-workspace-id={workspaceId}
1017-
>
1018-
×
1019-
</WorkspaceRemoveBtn>
1020-
<Tooltip className="tooltip" align="right">
1021-
Remove workspace
1022-
</Tooltip>
1023-
</TooltipWrapper>
1024-
<GitStatusIndicator
1025-
gitStatus={gitStatus.get(metadata.id) ?? null}
1026-
workspaceId={workspaceId}
1027-
tooltipPosition="right"
1028-
/>
1029-
{isEditing ? (
1030-
<WorkspaceNameInput
1031-
value={editingName}
1032-
onChange={(e) => setEditingName(e.target.value)}
1033-
onKeyDown={(e) => handleRenameKeyDown(e, workspaceId)}
1034-
onBlur={() => void confirmRename(workspaceId)}
1035-
autoFocus
1036-
onClick={(e) => e.stopPropagation()}
1037-
aria-label={`Rename workspace ${displayName}`}
1038-
data-workspace-id={workspaceId}
1018+
onKeyDown={(e) => {
1019+
if (e.key === "Enter" || e.key === " ") {
1020+
e.preventDefault();
1021+
onSelectWorkspace({
1022+
projectPath,
1023+
projectName,
1024+
workspacePath: workspace.path,
1025+
workspaceId,
1026+
});
1027+
}
1028+
}}
1029+
role="button"
1030+
tabIndex={0}
1031+
aria-current={isSelected ? "true" : undefined}
1032+
data-workspace-path={workspace.path}
1033+
data-workspace-id={workspaceId}
1034+
>
1035+
<TooltipWrapper inline>
1036+
<WorkspaceRemoveBtn
1037+
onClick={(e) => {
1038+
e.stopPropagation();
1039+
void handleRemoveWorkspace(workspaceId, e.currentTarget);
1040+
}}
1041+
aria-label={`Remove workspace ${displayName}`}
1042+
data-workspace-id={workspaceId}
1043+
>
1044+
×
1045+
</WorkspaceRemoveBtn>
1046+
<Tooltip className="tooltip" align="right">
1047+
Remove workspace
1048+
</Tooltip>
1049+
</TooltipWrapper>
1050+
<GitStatusIndicator
1051+
gitStatus={gitStatus.get(metadata.id) ?? null}
1052+
workspaceId={workspaceId}
1053+
tooltipPosition="right"
10391054
/>
1040-
) : (
1041-
<WorkspaceName
1042-
onDoubleClick={(e) => {
1043-
e.stopPropagation();
1044-
startRenaming(workspaceId, displayName);
1045-
}}
1046-
title="Double-click to rename"
1047-
>
1048-
{displayName}
1049-
</WorkspaceName>
1055+
{isEditing ? (
1056+
<WorkspaceNameInput
1057+
value={editingName}
1058+
onChange={(e) => setEditingName(e.target.value)}
1059+
onKeyDown={(e) => handleRenameKeyDown(e, workspaceId)}
1060+
onBlur={() => void confirmRename(workspaceId)}
1061+
autoFocus
1062+
onClick={(e) => e.stopPropagation()}
1063+
aria-label={`Rename workspace ${displayName}`}
1064+
data-workspace-id={workspaceId}
1065+
/>
1066+
) : (
1067+
<WorkspaceName
1068+
onDoubleClick={(e) => {
1069+
e.stopPropagation();
1070+
startRenaming(workspaceId, displayName);
1071+
}}
1072+
title="Double-click to rename"
1073+
>
1074+
{displayName}
1075+
</WorkspaceName>
1076+
)}
1077+
<WorkspaceStatusIndicator
1078+
streaming={isStreaming}
1079+
unread={isUnread}
1080+
onClick={() => _onToggleUnread(workspaceId)}
1081+
title={
1082+
isStreaming
1083+
? "Assistant is responding"
1084+
: isUnread
1085+
? "Unread messages"
1086+
: "Idle"
1087+
}
1088+
/>
1089+
</WorkspaceItem>
1090+
{renameError && editingWorkspaceId === workspaceId && (
1091+
<WorkspaceErrorContainer>{renameError}</WorkspaceErrorContainer>
10501092
)}
1051-
<WorkspaceStatusIndicator
1052-
streaming={isStreaming}
1053-
unread={isUnread}
1054-
onClick={() => _onToggleUnread(workspaceId)}
1055-
title={
1056-
isStreaming
1057-
? "Assistant is responding"
1058-
: isUnread
1059-
? "Unread messages"
1060-
: "Idle"
1061-
}
1062-
/>
1063-
</WorkspaceItem>
1064-
{renameError && editingWorkspaceId === workspaceId && (
1065-
<WorkspaceErrorContainer>{renameError}</WorkspaceErrorContainer>
1066-
)}
1067-
</React.Fragment>
1068-
);
1069-
})}
1093+
</React.Fragment>
1094+
);
1095+
}
1096+
)}
10701097
</WorkspacesContainer>
10711098
)}
10721099
</ProjectGroup>

src/hooks/useWorkspaceAggregators.ts

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState, useEffect, useRef, useCallback } from "react";
1+
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
22
import type { DisplayedMessage, CmuxMessage } from "@/types/message";
33
import { createCmuxMessage } from "@/types/message";
44
import type { WorkspaceMetadata } from "@/types/workspace";
@@ -30,6 +30,7 @@ export interface WorkspaceState {
3030
loading: boolean;
3131
cmuxMessages: CmuxMessage[];
3232
currentModel: string;
33+
lastUserMessageAt: number | null; // Timestamp of most recent user message (null if no user messages)
3334
}
3435

3536
/**
@@ -41,7 +42,7 @@ export interface WorkspaceState {
4142
export function useWorkspaceAggregators(workspaceMetadata: Map<string, WorkspaceMetadata>) {
4243
const aggregatorsRef = useRef<Map<string, StreamingMessageAggregator>>(new Map());
4344
// Force re-render when messages change for the selected workspace
44-
const [, setUpdateCounter] = useState(0);
45+
const [updateCounter, setUpdateCounter] = useState(0);
4546

4647
// Track recently used models
4748
const { addModel } = useModelLRU();
@@ -65,14 +66,25 @@ export function useWorkspaceAggregators(workspaceMetadata: Map<string, Workspace
6566
const aggregator = getAggregator(workspaceId);
6667
const hasMessages = aggregator.hasMessages();
6768
const isCaughtUp = caughtUpRef.current.get(workspaceId) ?? false;
69+
const activeStreams = aggregator.getActiveStreams();
70+
71+
// Get most recent user message timestamp (persisted, survives restarts)
72+
// Using user messages instead of assistant messages avoids constant reordering
73+
// when multiple concurrent streams are running
74+
const messages = aggregator.getAllMessages();
75+
const lastUserMsg = [...messages]
76+
.reverse()
77+
.find((m) => m.role === "user" && m.metadata?.timestamp);
78+
const lastUserMessageAt = lastUserMsg?.metadata?.timestamp ?? null;
6879

6980
return {
7081
messages: aggregator.getDisplayedMessages(),
71-
canInterrupt: aggregator.getActiveStreams().length > 0,
82+
canInterrupt: activeStreams.length > 0,
7283
isCompacting: aggregator.isCompacting(),
7384
loading: !hasMessages && !isCaughtUp,
7485
cmuxMessages: aggregator.getAllMessages(),
7586
currentModel: aggregator.getCurrentModel() ?? "claude-sonnet-4-5",
87+
lastUserMessageAt,
7688
};
7789
},
7890
[getAggregator]
@@ -296,15 +308,49 @@ export function useWorkspaceAggregators(workspaceMetadata: Map<string, Workspace
296308

297309
// Build workspaceStates map for consumers that need all states
298310
// Key by metadata.id (short format like 'cmux-md-toggles') to match localStorage keys
299-
const workspaceStates = new Map<string, WorkspaceState>();
300-
for (const [_key, metadata] of workspaceMetadata) {
301-
workspaceStates.set(metadata.id, getWorkspaceState(metadata.id));
302-
}
311+
// Memoized to prevent unnecessary re-renders of consumers (e.g., ProjectSidebar sorting)
312+
// Updates when messages change (updateCounter) or workspaces are added/removed (workspaceMetadata)
313+
const workspaceStates = useMemo(() => {
314+
const states = new Map<string, WorkspaceState>();
315+
for (const [_key, metadata] of workspaceMetadata) {
316+
states.set(metadata.id, getWorkspaceState(metadata.id));
317+
}
318+
return states;
319+
// eslint-disable-next-line react-hooks/exhaustive-deps
320+
}, [workspaceMetadata, getWorkspaceState, updateCounter]);
321+
322+
// Extract recency timestamps for sorting - only updates when timestamps actually change
323+
// This prevents unnecessary sort recomputation when unrelated workspace state changes
324+
const workspaceRecencyRef = useRef<Record<string, number>>({});
325+
const workspaceRecency = useMemo(() => {
326+
const timestamps: Record<string, number> = {};
327+
for (const [id, state] of workspaceStates) {
328+
if (state.lastUserMessageAt !== null) {
329+
timestamps[id] = state.lastUserMessageAt;
330+
}
331+
}
332+
333+
// Only return new object if timestamps actually changed
334+
const prev = workspaceRecencyRef.current;
335+
const prevKeys = Object.keys(prev);
336+
const newKeys = Object.keys(timestamps);
337+
338+
if (
339+
prevKeys.length === newKeys.length &&
340+
prevKeys.every((key) => prev[key] === timestamps[key])
341+
) {
342+
return prev; // No change, return previous reference
343+
}
344+
345+
workspaceRecencyRef.current = timestamps;
346+
return timestamps;
347+
}, [workspaceStates]);
303348

304349
return {
305350
getWorkspaceState,
306351
getAggregator,
307352
workspaceStates,
353+
workspaceRecency,
308354
forceUpdate,
309355
};
310356
}

0 commit comments

Comments
 (0)