feat: workspace source-of-truth hard cut

This commit is contained in:
Mohamed Boudra
2026-03-03 23:28:16 +07:00
parent f3796e2ba5
commit cb92f39e73
20 changed files with 1813 additions and 945 deletions
@@ -0,0 +1,18 @@
import type { WorkspaceDescriptor } from '@/stores/session-store'
import { projectDisplayNameFromProjectId } from '@/utils/project-display-name'
export function resolveWorkspaceHeader(input: {
workspace: WorkspaceDescriptor
}): { title: string; subtitle: string } {
return {
title: input.workspace.name,
subtitle: projectDisplayNameFromProjectId(input.workspace.projectId),
}
}
export function shouldRenderMissingWorkspaceDescriptor(input: {
workspace: WorkspaceDescriptor | null
hasHydratedWorkspaces: boolean
}): boolean {
return !input.workspace && input.hasHydratedWorkspaces
}
@@ -49,7 +49,10 @@ import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-an
import { useToast } from "@/contexts/toast-context";
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
import { usePanelStore, type ExplorerCheckoutContext } from "@/stores/panel-store";
import { useSessionStore, type Agent } from "@/stores/session-store";
import {
useSessionStore,
type Agent,
} from "@/stores/session-store";
import {
buildWorkspaceTabPersistenceKey,
useWorkspaceTabsStore,
@@ -80,11 +83,9 @@ import { WorkspaceDraftAgentTab } from "@/screens/workspace/workspace-draft-agen
import { WorkspaceDesktopTabsRow } from "@/screens/workspace/workspace-desktop-tabs-row";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import {
deriveProjectDisplayName,
deriveProjectKey,
deriveProjectName,
deriveRemoteProjectKey,
} from "@/utils/agent-grouping";
resolveWorkspaceHeader,
shouldRenderMissingWorkspaceDescriptor,
} from "@/screens/workspace/workspace-header-source";
const TERMINALS_QUERY_STALE_TIME = 5_000;
const DROPDOWN_WIDTH = 220;
@@ -152,24 +153,6 @@ function decodeSegment(value: string): string {
}
}
function deriveWorkspaceName(workspaceId: string): string {
const normalized = workspaceId.replace(/\\/g, "/");
const segments = normalized.split("/").filter(Boolean);
const last = segments[segments.length - 1];
return last ?? workspaceId;
}
function deriveWorkspaceProjectDisplayName(input: {
workspaceId: string;
checkout: CheckoutStatusPayload | null;
}): string {
const projectKey =
deriveRemoteProjectKey(input.checkout?.remoteUrl ?? null) ??
deriveProjectKey(input.workspaceId);
const projectName = deriveProjectName(projectKey);
return deriveProjectDisplayName({ projectKey, projectName });
}
function formatProviderLabel(provider: Agent["provider"]): string {
if (provider === "claude") {
return "Claude";
@@ -407,18 +390,17 @@ function WorkspaceScreenContent({
staleTime: 15_000,
});
const workspaceName = useMemo(
() => deriveWorkspaceName(normalizedWorkspaceId),
[normalizedWorkspaceId]
const workspaceDescriptor = useSessionStore(
(state) =>
state.sessions[normalizedServerId]?.workspaces.get(normalizedWorkspaceId) ??
null
);
const headerProjectName = useMemo(
() =>
deriveWorkspaceProjectDisplayName({
workspaceId: normalizedWorkspaceId,
checkout: checkoutQuery.data ?? null,
}),
[checkoutQuery.data, normalizedWorkspaceId]
const hasHydratedWorkspaces = useSessionStore(
(state) => state.sessions[normalizedServerId]?.hasHydratedWorkspaces ?? false
);
const workspaceHeader = workspaceDescriptor
? resolveWorkspaceHeader({ workspace: workspaceDescriptor })
: null;
const isGitCheckout = checkoutQuery.data?.isGit ?? false;
const areWorkspaceAgentsHydrated = useSessionStore(
@@ -1228,6 +1210,21 @@ function WorkspaceScreenContent({
}, [activeAgent, normalizedServerId, router]);
const renderContent = () => {
if (
shouldRenderMissingWorkspaceDescriptor({
workspace: workspaceDescriptor,
hasHydratedWorkspaces,
})
) {
return (
<View style={styles.emptyState}>
<Text style={styles.emptyStateText}>
Workspace descriptor missing for this route.
</Text>
</View>
);
}
const target = activeTab?.target ?? null;
if (!target) {
return (
@@ -1324,10 +1321,10 @@ function WorkspaceScreenContent({
<SidebarMenuToggle />
<View style={styles.headerTitleContainer}>
<Text style={styles.headerTitle} numberOfLines={1}>
{workspaceName}
{workspaceHeader ? workspaceHeader.title : "Loading workspace…"}
</Text>
<Text style={styles.headerProjectTitle} numberOfLines={1}>
{headerProjectName}
{workspaceHeader ? workspaceHeader.subtitle : "Awaiting workspace descriptor"}
</Text>
</View>
</>
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest'
import {
resolveWorkspaceHeader,
shouldRenderMissingWorkspaceDescriptor,
} from './workspace-header-source'
import { buildSidebarProjectsFromWorkspaces } from '@/hooks/use-sidebar-workspaces-list'
import type { WorkspaceDescriptor } from '@/stores/session-store'
describe('workspace source of truth consumption', () => {
it('uses the same descriptor name in header and sidebar row', () => {
const workspace: WorkspaceDescriptor = {
id: '/repo/main',
projectId: 'remote:github.com/getpaseo/paseo',
name: 'feat/workspace-sot',
status: 'running',
activityAt: new Date('2026-03-01T00:00:00.000Z'),
}
const header = resolveWorkspaceHeader({ workspace })
const sidebarProjects = buildSidebarProjectsFromWorkspaces({
serverId: 'srv',
workspaces: [workspace],
projectOrder: [],
workspaceOrderByScope: {},
})
expect(header.title).toBe('feat/workspace-sot')
expect(header.subtitle).toBe('getpaseo/paseo')
expect(sidebarProjects[0]?.workspaces[0]?.name).toBe(header.title)
expect(sidebarProjects[0]?.workspaces[0]?.statusBucket).toBe('running')
})
it('renders explicit missing state only after workspace hydration', () => {
expect(
shouldRenderMissingWorkspaceDescriptor({
workspace: null,
hasHydratedWorkspaces: true,
})
).toBe(true)
expect(
shouldRenderMissingWorkspaceDescriptor({
workspace: null,
hasHydratedWorkspaces: false,
})
).toBe(false)
})
})