Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ function AppInner() {
});

// Use workspace aggregators hook for message state
const { getWorkspaceState, getAggregator, workspaceStates } =
const { getWorkspaceState, getAggregator, workspaceStates, workspaceRecency } =
useWorkspaceAggregators(workspaceMetadata);

// Track unread message status for all workspaces
Expand Down Expand Up @@ -554,6 +554,7 @@ function AppInner() {
onToggleCollapsed={() => setSidebarCollapsed((prev) => !prev)}
onGetSecrets={handleGetSecrets}
onUpdateSecrets={handleUpdateSecrets}
workspaceRecency={workspaceRecency}
/>
<MainContent>
<ContentArea>
Expand Down
1 change: 1 addition & 0 deletions src/components/LeftSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ interface LeftSidebarProps {
onToggleCollapsed: () => void;
onGetSecrets: (projectPath: string) => Promise<Secret[]>;
onUpdateSecrets: (projectPath: string, secrets: Secret[]) => Promise<void>;
workspaceRecency: Record<string, number>;
}

export function LeftSidebar(props: LeftSidebarProps) {
Expand Down
221 changes: 124 additions & 97 deletions src/components/ProjectSidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import React, { useState, useEffect, useCallback, useRef } from "react";
import React, { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { createPortal } from "react-dom";
import styled from "@emotion/styled";
import { css } from "@emotion/react";
import type { ProjectConfig } from "@/config";
import type { ProjectConfig, Workspace } from "@/config";
import type { WorkspaceMetadata } from "@/types/workspace";
import { useGitStatus } from "@/contexts/GitStatusContext";
import { usePersistedState } from "@/hooks/usePersistedState";
Expand Down Expand Up @@ -585,6 +585,7 @@ interface ProjectSidebarProps {
onToggleCollapsed: () => void;
onGetSecrets: (projectPath: string) => Promise<Secret[]>;
onUpdateSecrets: (projectPath: string, secrets: Secret[]) => Promise<void>;
workspaceRecency: Record<string, number>;
}

const ProjectSidebar: React.FC<ProjectSidebarProps> = ({
Expand All @@ -604,10 +605,33 @@ const ProjectSidebar: React.FC<ProjectSidebarProps> = ({
onToggleCollapsed,
onGetSecrets,
onUpdateSecrets,
workspaceRecency,
}) => {
// Subscribe to git status updates (causes this component to re-render every 10s)
const gitStatus = useGitStatus();

// Sort workspaces by last user message (most recent first)
// workspaceRecency only updates when timestamps actually change (stable reference optimization)
const sortedWorkspacesByProject = useMemo(() => {
const result = new Map<string, Workspace[]>();
for (const [projectPath, config] of projects) {
result.set(
projectPath,
config.workspaces.slice().sort((a, b) => {
const aMeta = workspaceMetadata.get(a.path);
const bMeta = workspaceMetadata.get(b.path);
if (!aMeta || !bMeta) return 0;

// Get timestamp of most recent user message (0 if never used)
const aTimestamp = workspaceRecency[aMeta.id] ?? 0;
const bTimestamp = workspaceRecency[bMeta.id] ?? 0;
return bTimestamp - aTimestamp;
})
);
}
return result;
}, [projects, workspaceMetadata, workspaceRecency]);

// Store as array in localStorage, convert to Set for usage
const [expandedProjectsArray, setExpandedProjectsArray] = usePersistedState<string[]>(
"expandedProjects",
Expand Down Expand Up @@ -964,109 +988,112 @@ const ProjectSidebar: React.FC<ProjectSidebarProps> = ({
` (${formatKeybind(KEYBINDS.NEW_WORKSPACE)})`}
</AddWorkspaceBtn>
</WorkspaceHeader>
{config.workspaces.map((workspace) => {
const metadata = workspaceMetadata.get(workspace.path);
if (!metadata) return null;

const workspaceId = metadata.id;
const displayName = getWorkspaceDisplayName(workspace.path);
const workspaceState = getWorkspaceState(workspaceId);
const isStreaming = workspaceState.canInterrupt;
// const streamingModel = workspaceState.currentModel; // Unused
const isUnread = unreadStatus.get(workspaceId) ?? false;
const isEditing = editingWorkspaceId === workspaceId;
const isSelected = selectedWorkspace?.workspacePath === workspace.path;

return (
<React.Fragment key={workspace.path}>
<WorkspaceItem
selected={isSelected}
onClick={() =>
onSelectWorkspace({
projectPath,
projectName,
workspacePath: workspace.path,
workspaceId,
})
}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
{(sortedWorkspacesByProject.get(projectPath) ?? config.workspaces).map(
(workspace) => {
const metadata = workspaceMetadata.get(workspace.path);
if (!metadata) return null;

const workspaceId = metadata.id;
const displayName = getWorkspaceDisplayName(workspace.path);
const workspaceState = getWorkspaceState(workspaceId);
const isStreaming = workspaceState.canInterrupt;
// const streamingModel = workspaceState.currentModel; // Unused
const isUnread = unreadStatus.get(workspaceId) ?? false;
const isEditing = editingWorkspaceId === workspaceId;
const isSelected =
selectedWorkspace?.workspacePath === workspace.path;

return (
<React.Fragment key={workspace.path}>
<WorkspaceItem
selected={isSelected}
onClick={() =>
onSelectWorkspace({
projectPath,
projectName,
workspacePath: workspace.path,
workspaceId,
});
})
}
}}
role="button"
tabIndex={0}
aria-current={isSelected ? "true" : undefined}
data-workspace-path={workspace.path}
data-workspace-id={workspaceId}
>
<TooltipWrapper inline>
<WorkspaceRemoveBtn
onClick={(e) => {
e.stopPropagation();
void handleRemoveWorkspace(workspaceId, e.currentTarget);
}}
aria-label={`Remove workspace ${displayName}`}
data-workspace-id={workspaceId}
>
×
</WorkspaceRemoveBtn>
<Tooltip className="tooltip" align="right">
Remove workspace
</Tooltip>
</TooltipWrapper>
<GitStatusIndicator
gitStatus={gitStatus.get(metadata.id) ?? null}
workspaceId={workspaceId}
tooltipPosition="right"
/>
{isEditing ? (
<WorkspaceNameInput
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onKeyDown={(e) => handleRenameKeyDown(e, workspaceId)}
onBlur={() => void confirmRename(workspaceId)}
autoFocus
onClick={(e) => e.stopPropagation()}
aria-label={`Rename workspace ${displayName}`}
data-workspace-id={workspaceId}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelectWorkspace({
projectPath,
projectName,
workspacePath: workspace.path,
workspaceId,
});
}
}}
role="button"
tabIndex={0}
aria-current={isSelected ? "true" : undefined}
data-workspace-path={workspace.path}
data-workspace-id={workspaceId}
>
<TooltipWrapper inline>
<WorkspaceRemoveBtn
onClick={(e) => {
e.stopPropagation();
void handleRemoveWorkspace(workspaceId, e.currentTarget);
}}
aria-label={`Remove workspace ${displayName}`}
data-workspace-id={workspaceId}
>
×
</WorkspaceRemoveBtn>
<Tooltip className="tooltip" align="right">
Remove workspace
</Tooltip>
</TooltipWrapper>
<GitStatusIndicator
gitStatus={gitStatus.get(metadata.id) ?? null}
workspaceId={workspaceId}
tooltipPosition="right"
/>
) : (
<WorkspaceName
onDoubleClick={(e) => {
e.stopPropagation();
startRenaming(workspaceId, displayName);
}}
title="Double-click to rename"
>
{displayName}
</WorkspaceName>
{isEditing ? (
<WorkspaceNameInput
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onKeyDown={(e) => handleRenameKeyDown(e, workspaceId)}
onBlur={() => void confirmRename(workspaceId)}
autoFocus
onClick={(e) => e.stopPropagation()}
aria-label={`Rename workspace ${displayName}`}
data-workspace-id={workspaceId}
/>
) : (
<WorkspaceName
onDoubleClick={(e) => {
e.stopPropagation();
startRenaming(workspaceId, displayName);
}}
title="Double-click to rename"
>
{displayName}
</WorkspaceName>
)}
<WorkspaceStatusIndicator
streaming={isStreaming}
unread={isUnread}
onClick={() => _onToggleUnread(workspaceId)}
title={
isStreaming
? "Assistant is responding"
: isUnread
? "Unread messages"
: "Idle"
}
/>
</WorkspaceItem>
{renameError && editingWorkspaceId === workspaceId && (
<WorkspaceErrorContainer>{renameError}</WorkspaceErrorContainer>
)}
<WorkspaceStatusIndicator
streaming={isStreaming}
unread={isUnread}
onClick={() => _onToggleUnread(workspaceId)}
title={
isStreaming
? "Assistant is responding"
: isUnread
? "Unread messages"
: "Idle"
}
/>
</WorkspaceItem>
{renameError && editingWorkspaceId === workspaceId && (
<WorkspaceErrorContainer>{renameError}</WorkspaceErrorContainer>
)}
</React.Fragment>
);
})}
</React.Fragment>
);
}
)}
</WorkspacesContainer>
)}
</ProjectGroup>
Expand Down
60 changes: 53 additions & 7 deletions src/hooks/useWorkspaceAggregators.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import type { DisplayedMessage, CmuxMessage } from "@/types/message";
import { createCmuxMessage } from "@/types/message";
import type { WorkspaceMetadata } from "@/types/workspace";
Expand Down Expand Up @@ -30,6 +30,7 @@ export interface WorkspaceState {
loading: boolean;
cmuxMessages: CmuxMessage[];
currentModel: string;
lastUserMessageAt: number | null; // Timestamp of most recent user message (null if no user messages)
}

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

// Track recently used models
const { addModel } = useModelLRU();
Expand All @@ -65,14 +66,25 @@ export function useWorkspaceAggregators(workspaceMetadata: Map<string, Workspace
const aggregator = getAggregator(workspaceId);
const hasMessages = aggregator.hasMessages();
const isCaughtUp = caughtUpRef.current.get(workspaceId) ?? false;
const activeStreams = aggregator.getActiveStreams();

// Get most recent user message timestamp (persisted, survives restarts)
// Using user messages instead of assistant messages avoids constant reordering
// when multiple concurrent streams are running
const messages = aggregator.getAllMessages();
const lastUserMsg = [...messages]
.reverse()
.find((m) => m.role === "user" && m.metadata?.timestamp);
const lastUserMessageAt = lastUserMsg?.metadata?.timestamp ?? null;

return {
messages: aggregator.getDisplayedMessages(),
canInterrupt: aggregator.getActiveStreams().length > 0,
canInterrupt: activeStreams.length > 0,
isCompacting: aggregator.isCompacting(),
loading: !hasMessages && !isCaughtUp,
cmuxMessages: aggregator.getAllMessages(),
currentModel: aggregator.getCurrentModel() ?? "claude-sonnet-4-5",
lastUserMessageAt,
};
},
[getAggregator]
Expand Down Expand Up @@ -296,15 +308,49 @@ export function useWorkspaceAggregators(workspaceMetadata: Map<string, Workspace

// Build workspaceStates map for consumers that need all states
// Key by metadata.id (short format like 'cmux-md-toggles') to match localStorage keys
const workspaceStates = new Map<string, WorkspaceState>();
for (const [_key, metadata] of workspaceMetadata) {
workspaceStates.set(metadata.id, getWorkspaceState(metadata.id));
}
// Memoized to prevent unnecessary re-renders of consumers (e.g., ProjectSidebar sorting)
// Updates when messages change (updateCounter) or workspaces are added/removed (workspaceMetadata)
const workspaceStates = useMemo(() => {
const states = new Map<string, WorkspaceState>();
for (const [_key, metadata] of workspaceMetadata) {
states.set(metadata.id, getWorkspaceState(metadata.id));
}
return states;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [workspaceMetadata, getWorkspaceState, updateCounter]);

// Extract recency timestamps for sorting - only updates when timestamps actually change
// This prevents unnecessary sort recomputation when unrelated workspace state changes
const workspaceRecencyRef = useRef<Record<string, number>>({});
const workspaceRecency = useMemo(() => {
const timestamps: Record<string, number> = {};
for (const [id, state] of workspaceStates) {
if (state.lastUserMessageAt !== null) {
timestamps[id] = state.lastUserMessageAt;
}
}

// Only return new object if timestamps actually changed
const prev = workspaceRecencyRef.current;
const prevKeys = Object.keys(prev);
const newKeys = Object.keys(timestamps);

if (
prevKeys.length === newKeys.length &&
prevKeys.every((key) => prev[key] === timestamps[key])
) {
return prev; // No change, return previous reference
}

workspaceRecencyRef.current = timestamps;
return timestamps;
}, [workspaceStates]);

return {
getWorkspaceState,
getAggregator,
workspaceStates,
workspaceRecency,
forceUpdate,
};
}