Merge branch 'main' into dev

# Conflicts:
#	packages/server/src/server/session.ts
This commit is contained in:
Mohamed Boudra
2026-04-14 13:41:01 +07:00
12 changed files with 138 additions and 29 deletions
@@ -1225,6 +1225,8 @@ const styles = StyleSheet.create((theme) => ({
}, },
prefsButton: { prefsButton: {
height: 28, height: 28,
minWidth: 0,
flexShrink: 1,
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
gap: theme.spacing[1], gap: theme.spacing[1],
@@ -4,6 +4,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { ChevronDown, GitBranch } from "lucide-react-native"; import { ChevronDown, GitBranch } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox"; import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useToast } from "@/contexts/toast-context"; import { useToast } from "@/contexts/toast-context";
import { useBranchSwitcher } from "@/hooks/use-branch-switcher"; import { useBranchSwitcher } from "@/hooks/use-branch-switcher";
@@ -24,6 +25,7 @@ export function BranchSwitcher({
isGitCheckout, isGitCheckout,
}: BranchSwitcherProps) { }: BranchSwitcherProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const isCompact = useIsCompactFormFactor();
const anchorRef = useRef<View>(null); const anchorRef = useRef<View>(null);
const client = useHostRuntimeClient(serverId); const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId); const isConnected = useHostRuntimeIsConnected(serverId);
@@ -65,7 +67,7 @@ export function BranchSwitcher({
<Text testID="workspace-header-title" style={styles.headerTitle} numberOfLines={1}> <Text testID="workspace-header-title" style={styles.headerTitle} numberOfLines={1}>
{title} {title}
</Text> </Text>
<ChevronDown size={12} color={theme.colors.foregroundMuted} /> {!isCompact ? <ChevronDown size={12} color={theme.colors.foregroundMuted} /> : null}
</Pressable> </Pressable>
<Combobox <Combobox
options={branchOptions} options={branchOptions}
@@ -111,6 +113,10 @@ const styles = StyleSheet.create((theme) => ({
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
gap: theme.spacing[1], gap: theme.spacing[1],
marginLeft: {
xs: -theme.spacing[2],
md: 0,
},
paddingVertical: theme.spacing[1], paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[2], paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.md, borderRadius: theme.borderRadius.md,
@@ -644,7 +644,9 @@ export function CombinedModelSelector({
) : ( ) : (
<> <>
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} /> <ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={styles.triggerText}>{triggerLabel}</Text> <Text style={styles.triggerText} numberOfLines={1} ellipsizeMode="tail">
{triggerLabel}
</Text>
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} /> <ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</> </>
)} )}
@@ -714,6 +716,8 @@ export function CombinedModelSelector({
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
trigger: { trigger: {
height: 28, height: 28,
minWidth: 0,
flexShrink: 1,
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
backgroundColor: "transparent", backgroundColor: "transparent",
@@ -731,6 +735,8 @@ const styles = StyleSheet.create((theme) => ({
opacity: 0.5, opacity: 0.5,
}, },
triggerText: { triggerText: {
minWidth: 0,
flexShrink: 1,
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal, fontWeight: theme.fontWeight.normal,
@@ -1333,11 +1333,15 @@ const styles = StyleSheet.create(((theme: any) => ({
marginHorizontal: -6, marginHorizontal: -6,
}, },
leftButtonGroup: { leftButtonGroup: {
minWidth: 0,
flexShrink: 1,
flexGrow: 1,
flexDirection: "row", flexDirection: "row",
alignItems: "flex-end", alignItems: "flex-end",
gap: theme.spacing[1], gap: theme.spacing[1],
}, },
rightButtonGroup: { rightButtonGroup: {
flexShrink: 0,
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
gap: isWeb ? theme.spacing[2] : theme.spacing[1], gap: isWeb ? theme.spacing[2] : theme.spacing[1],
@@ -302,7 +302,7 @@ function WorkspaceStatusIndicator({
if (shouldShowSyncedLoader) { if (shouldShowSyncedLoader) {
return ( return (
<View style={styles.workspaceStatusDot}> <View style={styles.workspaceStatusDot}>
<SyncedLoader size={11} color={theme.colors.palette.amber[500]} /> <SyncedLoader size={11} color={theme.colorScheme === "light" ? theme.colors.palette.amber[700] : theme.colors.palette.amber[500]} />
</View> </View>
); );
} }
@@ -406,7 +406,7 @@ function ProjectLeadingVisual({
if (shouldShowSyncedLoader) { if (shouldShowSyncedLoader) {
return ( return (
<View style={styles.projectLeadingVisualSlot}> <View style={styles.projectLeadingVisualSlot}>
<SyncedLoader size={11} color={theme.colors.palette.amber[500]} /> <SyncedLoader size={11} color={theme.colorScheme === "light" ? theme.colors.palette.amber[700] : theme.colors.palette.amber[500]} />
</View> </View>
); );
} }
@@ -16,7 +16,7 @@ const DOT_COUNT = DOT_SEQUENCE.length;
const GRID_ROWS = 3; const GRID_ROWS = 3;
const GRID_COLUMNS = 2; const GRID_COLUMNS = 2;
const SNAKE_SEGMENT_OFFSETS = [0, -1, -2, -3, -4] as const; const SNAKE_SEGMENT_OFFSETS = [0, -1, -2, -3, -4] as const;
const SNAKE_OPACITIES = [1, 0.72, 0.46, 0.22, 0] as const; const SNAKE_OPACITIES = [1, 0.78, 0.56, 0.34, 0] as const;
const sharedStepProgress = makeMutable(0); const sharedStepProgress = makeMutable(0);
let sharedLoopStarted = false; let sharedLoopStarted = false;
@@ -146,6 +146,18 @@ function decodeSegment(value: string): string {
} }
} }
function areHeaderLabelsEquivalent(
a: string | null | undefined,
b: string | null | undefined,
): boolean {
const normalizedA = trimNonEmpty(a)?.toLocaleLowerCase();
const normalizedB = trimNonEmpty(b)?.toLocaleLowerCase();
if (!normalizedA || !normalizedB) {
return false;
}
return normalizedA === normalizedB;
}
function getFallbackTabOptionLabel(tab: WorkspaceTabDescriptor): string { function getFallbackTabOptionLabel(tab: WorkspaceTabDescriptor): string {
if (tab.target.kind === "draft") { if (tab.target.kind === "draft") {
return "New Agent"; return "New Agent";
@@ -773,6 +785,12 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
? resolveWorkspaceHeader({ workspace: workspaceDescriptor }) ? resolveWorkspaceHeader({ workspace: workspaceDescriptor })
: null; : null;
const isWorkspaceHeaderLoading = workspaceHeader === null; const isWorkspaceHeaderLoading = workspaceHeader === null;
const workspaceHeaderTitle = workspaceHeader?.title ?? "";
const workspaceHeaderSubtitle = workspaceHeader?.subtitle ?? "";
const shouldShowWorkspaceHeaderSubtitle = !areHeaderLabelsEquivalent(
workspaceHeaderTitle,
workspaceHeaderSubtitle,
);
const isGitCheckout = checkoutQuery.data?.isGit ?? false; const isGitCheckout = checkoutQuery.data?.isGit ?? false;
const currentBranchName = const currentBranchName =
@@ -2053,27 +2071,29 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
<SidebarMenuToggle /> <SidebarMenuToggle />
<View style={styles.headerTitleContainer}> <View style={styles.headerTitleContainer}>
{isWorkspaceHeaderLoading ? ( {isWorkspaceHeaderLoading ? (
<> <View style={styles.headerTitleTextGroup}>
<View style={styles.headerTitleSkeleton} /> <View style={styles.headerTitleSkeleton} />
<View style={styles.headerProjectTitleSkeleton} /> <View style={styles.headerProjectTitleSkeleton} />
</> </View>
) : ( ) : (
<> <View style={styles.headerTitleTextGroup}>
<BranchSwitcher <BranchSwitcher
currentBranchName={currentBranchName} currentBranchName={currentBranchName}
title={workspaceHeader.title} title={workspaceHeaderTitle}
serverId={normalizedServerId} serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId} workspaceId={normalizedWorkspaceId}
isGitCheckout={isGitCheckout} isGitCheckout={isGitCheckout}
/> />
<Text {shouldShowWorkspaceHeaderSubtitle ? (
testID="workspace-header-subtitle" <Text
style={styles.headerProjectTitle} testID="workspace-header-subtitle"
numberOfLines={1} style={styles.headerProjectTitle}
> numberOfLines={1}
{workspaceHeader.subtitle} >
</Text> {workspaceHeaderSubtitle}
</> </Text>
) : null}
</View>
)} )}
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
@@ -2409,15 +2429,40 @@ const styles = StyleSheet.create((theme) => ({
flexShrink: 1, flexShrink: 1,
}, },
headerTitleContainer: { headerTitleContainer: {
flex: 1,
flexShrink: 1, flexShrink: 1,
minWidth: 0, minWidth: 0,
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
gap: theme.spacing[2], gap: theme.spacing[2],
}, },
headerTitleTextGroup: {
minWidth: 0,
flexShrink: 1,
flexGrow: {
xs: 1,
md: 0,
},
flexDirection: {
xs: "column",
md: "row",
},
alignItems: {
xs: "flex-start",
md: "center",
},
justifyContent: "flex-start",
gap: {
xs: 0,
md: theme.spacing[2],
},
},
headerProjectTitle: { headerProjectTitle: {
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base, fontSize: {
xs: theme.fontSize.sm,
md: theme.fontSize.base,
},
flexShrink: 1, flexShrink: 1,
}, },
headerTitleSkeleton: { headerTitleSkeleton: {
@@ -134,7 +134,7 @@ export function WorkspaceTabIcon({
if (shouldShowLoader) { if (shouldShowLoader) {
return ( return (
<View style={[styles.agentIconWrapper, { width: size, height: size }]}> <View style={[styles.agentIconWrapper, { width: size, height: size }]}>
<SyncedLoader size={size - 1} color={theme.colors.palette.amber[500]} /> <SyncedLoader size={size - 1} color={theme.colorScheme === "light" ? theme.colors.palette.amber[700] : theme.colors.palette.amber[500]} />
</View> </View>
); );
} }
+1
View File
@@ -83,6 +83,7 @@ export const baseColors = {
// Amber scale // Amber scale
amber: { amber: {
500: "#f59e0b", 500: "#f59e0b",
700: "#b45309",
}, },
// Yellow scale // Yellow scale
+7
View File
@@ -113,6 +113,7 @@ import type { StoredAgentRecord } from "./agent/agent-storage.js";
import type { AgentStorage } from "./agent/agent-storage.js"; import type { AgentStorage } from "./agent/agent-storage.js";
import { AGENT_PROVIDER_IDS } from "./agent/provider-manifest.js"; import { AGENT_PROVIDER_IDS } from "./agent/provider-manifest.js";
import { import {
checkoutLiteFromGitSnapshot,
normalizeWorkspaceId as normalizePersistedWorkspaceId, normalizeWorkspaceId as normalizePersistedWorkspaceId,
deriveWorkspaceId, deriveWorkspaceId,
deriveProjectRootPath, deriveProjectRootPath,
@@ -5716,8 +5717,14 @@ export class Session {
if (!snapshot) { if (!snapshot) {
return base; return base;
} }
const checkout = checkoutLiteFromGitSnapshot(workspace.cwd, snapshot.git);
const displayName = deriveWorkspaceDisplayName({ cwd: workspace.cwd, checkout });
return { return {
...base, ...base,
name: displayName,
diffStat: snapshot.git.diffStat ?? null,
gitRuntime: this.buildWorkspaceGitRuntimePayload(snapshot) ?? undefined, gitRuntime: this.buildWorkspaceGitRuntimePayload(snapshot) ?? undefined,
githubRuntime: this.buildWorkspaceGitHubRuntimePayload(snapshot), githubRuntime: this.buildWorkspaceGitHubRuntimePayload(snapshot),
}; };
@@ -1890,9 +1890,7 @@ describe("workspace aggregation", () => {
}); });
const workspaceGitService = createNoopWorkspaceGitService(); const workspaceGitService = createNoopWorkspaceGitService();
workspaceGitService.peekSnapshot = vi.fn(() => runtimeSnapshot); workspaceGitService.peekSnapshot = vi.fn(() => runtimeSnapshot);
workspaceGitService.getSnapshot = vi.fn(async () => { workspaceGitService.getSnapshot = vi.fn(async () => runtimeSnapshot);
throw new Error("fetch_workspaces should not trigger per-workspace refreshes");
});
workspaceGitService.subscribe = vi.fn(async () => ({ workspaceGitService.subscribe = vi.fn(async () => ({
initial: runtimeSnapshot, initial: runtimeSnapshot,
unsubscribe: () => {}, unsubscribe: () => {},
@@ -1946,8 +1944,7 @@ describe("workspace aggregation", () => {
| { type: "fetch_workspaces_response"; payload: any } | { type: "fetch_workspaces_response"; payload: any }
| undefined; | undefined;
expect(workspaceGitService.peekSnapshot).toHaveBeenCalledWith("/tmp/repo"); expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo");
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
expect(response?.payload.entries).toEqual([ expect(response?.payload.entries).toEqual([
expect.objectContaining({ expect.objectContaining({
id: "/tmp/repo", id: "/tmp/repo",
@@ -1998,9 +1995,7 @@ describe("workspace aggregation", () => {
}); });
const workspaceGitService = createNoopWorkspaceGitService(); const workspaceGitService = createNoopWorkspaceGitService();
workspaceGitService.peekSnapshot = vi.fn(() => runtimeSnapshot); workspaceGitService.peekSnapshot = vi.fn(() => runtimeSnapshot);
workspaceGitService.getSnapshot = vi.fn(async () => { workspaceGitService.getSnapshot = vi.fn(async () => runtimeSnapshot);
throw new Error("workspace updates should use passive workspace git snapshots");
});
const session = createSessionForWorkspaceTests({ const session = createSessionForWorkspaceTests({
workspaceGitService, workspaceGitService,
@@ -2053,8 +2048,7 @@ describe("workspace aggregation", () => {
skipReconcile: true, skipReconcile: true,
}); });
expect(workspaceGitService.peekSnapshot).toHaveBeenCalledWith("/tmp/repo"); expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo");
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
expect(emitted).toContainEqual({ expect(emitted).toContainEqual({
type: "workspace_update", type: "workspace_update",
payload: { payload: {
@@ -137,6 +137,50 @@ export function deriveWorkspaceKind(checkout: ProjectCheckoutLitePayload): Persi
return checkout.isPaseoOwnedWorktree ? "worktree" : "local_checkout"; return checkout.isPaseoOwnedWorktree ? "worktree" : "local_checkout";
} }
export function checkoutLiteFromGitSnapshot(
cwd: string,
git: {
isGit: boolean;
currentBranch: string | null;
remoteUrl: string | null;
repoRoot: string | null;
isPaseoOwnedWorktree: boolean;
mainRepoRoot: string | null;
},
): ProjectCheckoutLitePayload {
if (!git.isGit) {
return {
cwd,
isGit: false,
currentBranch: null,
remoteUrl: null,
worktreeRoot: null,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
};
}
if (git.isPaseoOwnedWorktree && git.mainRepoRoot) {
return {
cwd,
isGit: true,
currentBranch: git.currentBranch,
remoteUrl: git.remoteUrl,
worktreeRoot: git.repoRoot ?? cwd,
isPaseoOwnedWorktree: true,
mainRepoRoot: git.mainRepoRoot,
};
}
return {
cwd,
isGit: true,
currentBranch: git.currentBranch,
remoteUrl: git.remoteUrl,
worktreeRoot: git.repoRoot ?? cwd,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
};
}
export async function detectStaleWorkspaces( export async function detectStaleWorkspaces(
input: DetectStaleWorkspacesInput, input: DetectStaleWorkspacesInput,
): Promise<Set<string>> { ): Promise<Set<string>> {