Find and open workspaces from the command center (#2096)
* feat(app): add workspaces to command center * fix(app): scale down command center typography * fix(app): avoid workspace theme subscription * fix(app): theme command center bottom sheet * fix(app): tighten command center workspace results
This commit is contained in:
@@ -20,17 +20,24 @@ import { getIsElectronRuntime } from "@/constants/layout";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
import { focusWithRetries } from "@/utils/web-focus";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useProjects } from "@/hooks/use-projects";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import { shortenPath } from "@/utils/shorten-path";
|
||||
|
||||
const EMPTY_AGENTS: AggregatedAgent[] = [];
|
||||
const EMPTY_ACTION_ITEMS: CommandCenterActionItem[] = [];
|
||||
const EMPTY_WORKSPACE_ITEMS: CommandCenterWorkspaceItem[] = [];
|
||||
const EMPTY_AGENT_ITEMS: CommandCenterAgentItem[] = [];
|
||||
const EMPTY_COMMAND_CENTER_ITEMS: CommandCenterItem[] = [];
|
||||
|
||||
function isMatch(agent: AggregatedAgent, query: string): boolean {
|
||||
if (!query) return true;
|
||||
const q = query.toLowerCase();
|
||||
const title = (agent.title ?? "New agent").toLowerCase();
|
||||
const cwd = agent.cwd.toLowerCase();
|
||||
return title.includes(q) || cwd.includes(q);
|
||||
function buildSearchText(...fields: string[]): string {
|
||||
return fields.join(" ").toLowerCase();
|
||||
}
|
||||
|
||||
function matchesQuery(searchText: string, query: string): boolean {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
return !normalized || searchText.includes(normalized);
|
||||
}
|
||||
|
||||
function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number {
|
||||
@@ -86,19 +93,6 @@ const COMMAND_CENTER_ACTIONS: readonly CommandCenterActionDefinition[] = [
|
||||
},
|
||||
];
|
||||
|
||||
function matchesActionQuery(
|
||||
query: string,
|
||||
action: CommandCenterActionDefinition,
|
||||
title: string,
|
||||
): boolean {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
if (!normalized) return true;
|
||||
if (title.toLowerCase().includes(normalized)) {
|
||||
return true;
|
||||
}
|
||||
return action.keywords.some((keyword) => keyword.includes(normalized));
|
||||
}
|
||||
|
||||
export interface CommandCenterActionItem {
|
||||
kind: "action";
|
||||
id: string;
|
||||
@@ -106,17 +100,30 @@ export interface CommandCenterActionItem {
|
||||
icon?: "plus" | "settings" | "home";
|
||||
route?: Href;
|
||||
shortcutKeys?: ShortcutKey[][];
|
||||
searchText: string;
|
||||
}
|
||||
|
||||
export interface CommandCenterWorkspaceItem {
|
||||
kind: "workspace";
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
searchText: string;
|
||||
}
|
||||
|
||||
export interface CommandCenterAgentItem {
|
||||
kind: "agent";
|
||||
agent: AggregatedAgent;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
searchText: string;
|
||||
}
|
||||
|
||||
export type CommandCenterItem =
|
||||
| {
|
||||
kind: "action";
|
||||
action: CommandCenterActionItem;
|
||||
}
|
||||
| {
|
||||
kind: "agent";
|
||||
agent: AggregatedAgent;
|
||||
};
|
||||
| CommandCenterActionItem
|
||||
| CommandCenterWorkspaceItem
|
||||
| CommandCenterAgentItem;
|
||||
|
||||
function resolveActionShortcutKeys(
|
||||
actionId: string | undefined,
|
||||
@@ -150,15 +157,96 @@ export function useCommandCenter() {
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
|
||||
const { agents } = useAggregatedAgents();
|
||||
const { projects } = useProjects({ enabled: open });
|
||||
const hosts = useHosts();
|
||||
const showAgentHost = hosts.length > 1;
|
||||
|
||||
const allWorkspaceItems = useMemo(() => {
|
||||
const results: CommandCenterWorkspaceItem[] = [];
|
||||
for (const project of projects) {
|
||||
for (const host of project.hosts) {
|
||||
for (const workspace of host.workspaces) {
|
||||
if (workspace.archivingAt) continue;
|
||||
const title = workspace.title ?? workspace.name;
|
||||
const subtitle = workspace.currentBranch
|
||||
? `${host.serverName} · ${workspace.currentBranch}`
|
||||
: host.serverName;
|
||||
results.push({
|
||||
kind: "workspace",
|
||||
serverId: host.serverId,
|
||||
workspaceId: workspace.id,
|
||||
title,
|
||||
subtitle,
|
||||
searchText: buildSearchText(title, subtitle),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
results.sort((left, right) => {
|
||||
const titleDelta = left.title.localeCompare(right.title, undefined, {
|
||||
numeric: true,
|
||||
sensitivity: "base",
|
||||
});
|
||||
if (titleDelta !== 0) return titleDelta;
|
||||
const hostDelta = left.subtitle.localeCompare(right.subtitle, undefined, {
|
||||
numeric: true,
|
||||
sensitivity: "base",
|
||||
});
|
||||
if (hostDelta !== 0) return hostDelta;
|
||||
return `${left.serverId}:${left.workspaceId}`.localeCompare(
|
||||
`${right.serverId}:${right.workspaceId}`,
|
||||
);
|
||||
});
|
||||
return results;
|
||||
}, [projects]);
|
||||
|
||||
const workspaceTitleByKey = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
allWorkspaceItems.map((workspace) => [
|
||||
`${workspace.serverId}:${workspace.workspaceId}`,
|
||||
workspace.title,
|
||||
]),
|
||||
),
|
||||
[allWorkspaceItems],
|
||||
);
|
||||
|
||||
const workspaceResults = useMemo(() => {
|
||||
if (!open || allWorkspaceItems.length === 0) {
|
||||
return EMPTY_WORKSPACE_ITEMS;
|
||||
}
|
||||
return allWorkspaceItems.filter((workspace) => matchesQuery(workspace.searchText, query));
|
||||
}, [allWorkspaceItems, open, query]);
|
||||
|
||||
const agentResults = useMemo(() => {
|
||||
if (!open || agents.length === 0) {
|
||||
return EMPTY_AGENTS;
|
||||
return EMPTY_AGENT_ITEMS;
|
||||
}
|
||||
const filtered = agents.filter((agent) => isMatch(agent, query));
|
||||
filtered.sort(sortAgents);
|
||||
const items = agents.map<CommandCenterAgentItem>((agent) => {
|
||||
const title = agent.title || t("shell.commandCenter.newAgent");
|
||||
const workspaceTitle = agent.workspaceId
|
||||
? workspaceTitleByKey.get(`${agent.serverId}:${agent.workspaceId}`)
|
||||
: undefined;
|
||||
const location = workspaceTitle ?? shortenPath(agent.cwd);
|
||||
const subtitle = [
|
||||
showAgentHost ? agent.serverLabel : null,
|
||||
location,
|
||||
formatTimeAgo(agent.lastActivityAt),
|
||||
]
|
||||
.filter((part): part is string => Boolean(part))
|
||||
.join(" · ");
|
||||
return {
|
||||
kind: "agent",
|
||||
agent,
|
||||
title,
|
||||
subtitle,
|
||||
searchText: buildSearchText(title, subtitle, agent.cwd),
|
||||
};
|
||||
});
|
||||
const filtered = items.filter((item) => matchesQuery(item.searchText, query));
|
||||
filtered.sort((left, right) => sortAgents(left.agent, right.agent));
|
||||
return filtered;
|
||||
}, [agents, open, query]);
|
||||
}, [agents, open, query, showAgentHost, t, workspaceTitleByKey]);
|
||||
|
||||
const settingsRoute = useMemo<Href>(() => {
|
||||
return buildSettingsRoute();
|
||||
@@ -170,43 +258,33 @@ export function useCommandCenter() {
|
||||
if (!open) {
|
||||
return EMPTY_ACTION_ITEMS;
|
||||
}
|
||||
return COMMAND_CENTER_ACTIONS.filter((action) => {
|
||||
if (action.routeKind === "home" && !homeRoute) return false;
|
||||
return matchesActionQuery(query, action, t(action.titleKey));
|
||||
}).map<CommandCenterActionItem>((action) => {
|
||||
let route: Href | undefined;
|
||||
if (action.routeKind === "settings") route = settingsRoute;
|
||||
else if (action.routeKind === "home") route = homeRoute;
|
||||
return {
|
||||
kind: "action",
|
||||
id: action.id,
|
||||
title: t(action.titleKey),
|
||||
icon: action.icon,
|
||||
route,
|
||||
shortcutKeys: resolveActionShortcutKeys(action.actionId, overrides),
|
||||
};
|
||||
});
|
||||
return COMMAND_CENTER_ACTIONS.filter(
|
||||
(action) => action.routeKind !== "home" || Boolean(homeRoute),
|
||||
)
|
||||
.map<CommandCenterActionItem>((action) => {
|
||||
let route: Href | undefined;
|
||||
if (action.routeKind === "settings") route = settingsRoute;
|
||||
else if (action.routeKind === "home") route = homeRoute;
|
||||
const title = t(action.titleKey);
|
||||
return {
|
||||
kind: "action",
|
||||
id: action.id,
|
||||
title,
|
||||
icon: action.icon,
|
||||
route,
|
||||
shortcutKeys: resolveActionShortcutKeys(action.actionId, overrides),
|
||||
searchText: buildSearchText(title, ...action.keywords),
|
||||
};
|
||||
})
|
||||
.filter((action) => matchesQuery(action.searchText, query));
|
||||
}, [open, query, settingsRoute, homeRoute, overrides, t]);
|
||||
|
||||
const items = useMemo(() => {
|
||||
if (!open) {
|
||||
return EMPTY_COMMAND_CENTER_ITEMS;
|
||||
}
|
||||
const next: CommandCenterItem[] = [];
|
||||
for (const action of actionItems) {
|
||||
next.push({
|
||||
kind: "action",
|
||||
action,
|
||||
});
|
||||
}
|
||||
for (const agent of agentResults) {
|
||||
next.push({
|
||||
kind: "agent",
|
||||
agent,
|
||||
});
|
||||
}
|
||||
return next;
|
||||
}, [actionItems, agentResults, open]);
|
||||
return [...actionItems, ...workspaceResults, ...agentResults];
|
||||
}, [actionItems, workspaceResults, agentResults, open]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setOpen(false);
|
||||
@@ -227,6 +305,19 @@ export function useCommandCenter() {
|
||||
[setOpen],
|
||||
);
|
||||
|
||||
const handleSelectWorkspace = useCallback(
|
||||
(workspace: CommandCenterWorkspaceItem) => {
|
||||
didNavigateRef.current = true;
|
||||
clearCommandCenterFocusRestoreElement();
|
||||
setOpen(false);
|
||||
navigateToWorkspace({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
});
|
||||
},
|
||||
[setOpen],
|
||||
);
|
||||
|
||||
const openProjectPicker = useOpenProjectPicker();
|
||||
|
||||
const handleSelectAction = useCallback(
|
||||
@@ -249,12 +340,16 @@ export function useCommandCenter() {
|
||||
const handleSelectItem = useCallback(
|
||||
(item: CommandCenterItem) => {
|
||||
if (item.kind === "action") {
|
||||
handleSelectAction(item.action);
|
||||
handleSelectAction(item);
|
||||
return;
|
||||
}
|
||||
if (item.kind === "workspace") {
|
||||
handleSelectWorkspace(item);
|
||||
return;
|
||||
}
|
||||
handleSelectAgent(item.agent);
|
||||
},
|
||||
[handleSelectAction, handleSelectAgent],
|
||||
[handleSelectAction, handleSelectAgent, handleSelectWorkspace],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -50,6 +50,13 @@ export interface UseProjectsResult {
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
export interface UseProjectsOptions {
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
const EMPTY_PROJECT_HOST_REPLICAS: ProjectHostReplica[] = [];
|
||||
const EMPTY_PROJECT_HOST_RUNTIME_STATES: ProjectHostRuntimeState[] = [];
|
||||
|
||||
function toProjectHostRuntimeState(
|
||||
serverId: string,
|
||||
snapshot: HostRuntimeSnapshot | null,
|
||||
@@ -68,7 +75,11 @@ function toProjectHostRuntimeState(
|
||||
|
||||
function selectProjectHostReplicas(
|
||||
hosts: readonly { serverId: string; label: string }[],
|
||||
enabled: boolean,
|
||||
): (state: ReturnType<typeof useSessionStore.getState>) => ProjectHostReplica[] {
|
||||
if (!enabled) {
|
||||
return () => EMPTY_PROJECT_HOST_REPLICAS;
|
||||
}
|
||||
return (state) =>
|
||||
hosts.map((host) => {
|
||||
const session = state.sessions[host.serverId];
|
||||
@@ -119,15 +130,24 @@ export function deriveProjectsFromReplica(input: {
|
||||
};
|
||||
}
|
||||
|
||||
function useProjectHostRuntimeStates(serverIds: readonly string[]): ProjectHostRuntimeState[] {
|
||||
function useProjectHostRuntimeStates(
|
||||
serverIds: readonly string[],
|
||||
enabled: boolean,
|
||||
): ProjectHostRuntimeState[] {
|
||||
const runtime = getHostRuntimeStore();
|
||||
const previousStatesRef = useRef<ProjectHostRuntimeState[]>([]);
|
||||
const runtimeSnapshotTick = useSyncExternalStore(
|
||||
(onStoreChange) => runtime.subscribeAll(onStoreChange),
|
||||
() => runtime.getVersion(),
|
||||
() => runtime.getVersion(),
|
||||
const subscribe = useCallback(
|
||||
(onStoreChange: () => void) =>
|
||||
enabled ? runtime.subscribeAll(onStoreChange) : () => undefined,
|
||||
[enabled, runtime],
|
||||
);
|
||||
const getSnapshot = useCallback(() => (enabled ? runtime.getVersion() : 0), [enabled, runtime]);
|
||||
const runtimeSnapshotTick = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
return useMemo(() => {
|
||||
if (!enabled) {
|
||||
previousStatesRef.current = EMPTY_PROJECT_HOST_RUNTIME_STATES;
|
||||
return EMPTY_PROJECT_HOST_RUNTIME_STATES;
|
||||
}
|
||||
void runtimeSnapshotTick;
|
||||
const nextStates = serverIds.map((serverId) =>
|
||||
toProjectHostRuntimeState(serverId, runtime.getSnapshot(serverId)),
|
||||
@@ -137,22 +157,31 @@ function useProjectHostRuntimeStates(serverIds: readonly string[]): ProjectHostR
|
||||
}
|
||||
previousStatesRef.current = nextStates;
|
||||
return nextStates;
|
||||
}, [runtime, runtimeSnapshotTick, serverIds]);
|
||||
}, [enabled, runtime, runtimeSnapshotTick, serverIds]);
|
||||
}
|
||||
|
||||
export function useProjects(): UseProjectsResult {
|
||||
export function useProjects(options: UseProjectsOptions = {}): UseProjectsResult {
|
||||
const enabled = options.enabled ?? true;
|
||||
const hosts = useHosts();
|
||||
const runtime = getHostRuntimeStore();
|
||||
const serverIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]);
|
||||
const replicas = useStoreWithEqualityFn(useSessionStore, selectProjectHostReplicas(hosts), equal);
|
||||
const runtimeStates = useProjectHostRuntimeStates(serverIds);
|
||||
const serverIds = useMemo(
|
||||
() => (enabled ? hosts.map((host) => host.serverId) : []),
|
||||
[enabled, hosts],
|
||||
);
|
||||
const replicaSelector = useMemo(
|
||||
() => selectProjectHostReplicas(hosts, enabled),
|
||||
[enabled, hosts],
|
||||
);
|
||||
const replicas = useStoreWithEqualityFn(useSessionStore, replicaSelector, equal);
|
||||
const runtimeStates = useProjectHostRuntimeStates(serverIds, enabled);
|
||||
const derived = useMemo(
|
||||
() => deriveProjectsFromReplica({ replicas, runtimeStates }),
|
||||
[replicas, runtimeStates],
|
||||
);
|
||||
const refetch = useCallback(() => {
|
||||
if (!enabled) return;
|
||||
runtime.refreshAllAgentDirectories({ serverIds });
|
||||
}, [runtime, serverIds]);
|
||||
}, [enabled, runtime, serverIds]);
|
||||
|
||||
return {
|
||||
...derived,
|
||||
|
||||
Reference in New Issue
Block a user