Merge remote-tracking branch 'origin/main' into merge/main-into-dev
# Conflicts: # CLAUDE.md # nix/package.nix # packages/app/src/components/combined-model-selector.tsx # packages/app/src/hooks/use-sidebar-workspaces-list.ts # packages/app/src/screens/workspace/workspace-screen.tsx # packages/app/src/stores/session-store.test.ts # packages/server/src/client/daemon-client.ts # packages/server/src/server/agent/agent-manager.ts # packages/server/src/server/session.ts # packages/server/src/server/session.workspace-git-watch.test.ts # packages/server/src/server/session.workspaces.test.ts # packages/server/src/shared/messages.ts
This commit is contained in:
@@ -30,6 +30,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
width: "100%",
|
||||
maxWidth: 520,
|
||||
maxHeight: "85%",
|
||||
flexShrink: 1,
|
||||
minHeight: 0,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
borderWidth: 1,
|
||||
@@ -61,11 +63,13 @@ const styles = StyleSheet.create((theme) => ({
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
desktopScroll: {
|
||||
flex: 1,
|
||||
flexShrink: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
desktopContent: {
|
||||
padding: theme.spacing[6],
|
||||
gap: theme.spacing[4],
|
||||
flexGrow: 1,
|
||||
},
|
||||
bottomSheetHandle: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
|
||||
@@ -15,8 +15,8 @@ import {
|
||||
} from "lucide-react-native";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { CombinedModelSelector } from "@/components/combined-model-selector";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import {
|
||||
buildFavoriteModelKey,
|
||||
mergeProviderPreferences,
|
||||
@@ -51,7 +51,6 @@ import {
|
||||
getStatusSelectorHint,
|
||||
resolveAgentModelSelection,
|
||||
} from "@/components/agent-status-bar.utils";
|
||||
import { isProviderModelsQueryLoading } from "@/components/agent-status-bar.model-loading";
|
||||
|
||||
type StatusOption = {
|
||||
id: string;
|
||||
@@ -75,6 +74,7 @@ type ControlledAgentStatusBarProps = {
|
||||
modelOptions?: StatusOption[];
|
||||
selectedModelId?: string;
|
||||
onSelectModel?: (modelId: string) => void;
|
||||
onSelectProviderAndModel?: (provider: string, modelId: string) => void;
|
||||
thinkingOptions?: StatusOption[];
|
||||
selectedThinkingOptionId?: string;
|
||||
onSelectThinkingOption?: (thinkingOptionId: string) => void;
|
||||
@@ -203,6 +203,7 @@ function ControlledStatusBar({
|
||||
modelOptions,
|
||||
selectedModelId,
|
||||
onSelectModel,
|
||||
onSelectProviderAndModel,
|
||||
thinkingOptions,
|
||||
selectedThinkingOptionId,
|
||||
onSelectThinkingOption,
|
||||
@@ -264,7 +265,7 @@ function ControlledStatusBar({
|
||||
return null;
|
||||
}
|
||||
|
||||
const modelDisabled = disabled || isModelLoading || !modelOptions || modelOptions.length === 0;
|
||||
const modelDisabled = disabled;
|
||||
|
||||
const SEARCH_THRESHOLD = 6;
|
||||
|
||||
@@ -648,10 +649,14 @@ function ControlledStatusBar({
|
||||
selectedModel={selectedModelId ?? ""}
|
||||
canSelectProvider={canSelectProviderInModelMenu}
|
||||
onSelect={(selectedProviderId, modelId) => {
|
||||
if (selectedProviderId !== provider) {
|
||||
onSelectProvider?.(selectedProviderId);
|
||||
if (onSelectProviderAndModel) {
|
||||
onSelectProviderAndModel(selectedProviderId, modelId);
|
||||
} else {
|
||||
if (selectedProviderId !== provider) {
|
||||
onSelectProvider?.(selectedProviderId);
|
||||
}
|
||||
onSelectModel?.(modelId);
|
||||
}
|
||||
onSelectModel?.(modelId);
|
||||
}}
|
||||
favoriteKeys={favoriteKeys}
|
||||
onToggleFavorite={onToggleFavoriteModel}
|
||||
@@ -667,6 +672,7 @@ function ControlledStatusBar({
|
||||
pointerEvents="none"
|
||||
testID="agent-preferences-model"
|
||||
>
|
||||
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.sheetSelectText}>{selectedModelLabel}</Text>
|
||||
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
@@ -692,6 +698,7 @@ function ControlledStatusBar({
|
||||
accessibilityLabel="Select thinking option"
|
||||
testID="agent-preferences-thinking"
|
||||
>
|
||||
<Brain size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.sheetSelectText}>{displayThinking}</Text>
|
||||
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
</DropdownMenuTrigger>
|
||||
@@ -852,6 +859,7 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat
|
||||
model: currentAgent.model,
|
||||
features: currentAgent.features,
|
||||
thinkingOptionId: currentAgent.thinkingOptionId,
|
||||
lastUsage: currentAgent.lastUsage,
|
||||
}
|
||||
: null;
|
||||
}),
|
||||
@@ -863,52 +871,34 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat
|
||||
);
|
||||
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
||||
|
||||
const modelsQuery = useQuery({
|
||||
queryKey: ["providerModels", serverId, agent?.provider ?? "__missing_provider__"],
|
||||
enabled: Boolean(client && agent?.provider),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
queryFn: async () => {
|
||||
if (!client || !agent) {
|
||||
throw new Error("Daemon client unavailable");
|
||||
}
|
||||
const payload = await client.listProviderModels(agent.provider, { cwd: agent.cwd });
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
return payload.models ?? [];
|
||||
},
|
||||
});
|
||||
const {
|
||||
entries: snapshotEntries,
|
||||
isLoading: snapshotIsLoading,
|
||||
isFetching: snapshotIsFetching,
|
||||
} = useProvidersSnapshot(serverId);
|
||||
|
||||
const snapshotModels = useMemo(() => {
|
||||
if (!snapshotEntries || !agent?.provider) {
|
||||
return null;
|
||||
}
|
||||
const entry = snapshotEntries.find((e) => e.provider === agent.provider);
|
||||
return entry?.models ?? null;
|
||||
}, [snapshotEntries, agent?.provider]);
|
||||
|
||||
const models = snapshotModels;
|
||||
|
||||
const agentProviderDefinitions = useMemo(() => {
|
||||
const definition = AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === agent?.provider);
|
||||
return definition ? [definition] : [];
|
||||
}, [agent?.provider]);
|
||||
|
||||
const agentProviderModelQuery = useQuery({
|
||||
queryKey: ["providerModels", serverId, agent?.provider, agent?.cwd ?? ""],
|
||||
enabled: Boolean(client && agent?.cwd && agent?.provider),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
queryFn: async () => {
|
||||
if (!client || !agent) {
|
||||
throw new Error("Daemon client unavailable");
|
||||
}
|
||||
const payload = await client.listProviderModels(agent.provider, { cwd: agent.cwd });
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
return payload.models ?? [];
|
||||
},
|
||||
});
|
||||
|
||||
const agentProviderModels = useMemo(() => {
|
||||
const map = new Map<string, AgentModelDefinition[]>();
|
||||
if (agent?.provider && agentProviderModelQuery.data) {
|
||||
map.set(agent.provider, agentProviderModelQuery.data);
|
||||
if (agent?.provider && snapshotModels) {
|
||||
map.set(agent.provider, snapshotModels);
|
||||
}
|
||||
return map;
|
||||
}, [agent?.provider, agentProviderModelQuery.data]);
|
||||
|
||||
const models = modelsQuery.data ?? null;
|
||||
}, [agent?.provider, snapshotModels]);
|
||||
|
||||
const displayMode =
|
||||
availableModes.find((mode) => mode.id === agent?.currentModeId)?.label ||
|
||||
@@ -1028,7 +1018,7 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat
|
||||
console.warn("[AgentStatusBar] setAgentFeature failed", error);
|
||||
});
|
||||
}}
|
||||
isModelLoading={isProviderModelsQueryLoading(modelsQuery)}
|
||||
isModelLoading={snapshotIsLoading || snapshotIsFetching}
|
||||
onDropdownClose={onDropdownClose}
|
||||
disabled={!client}
|
||||
/>
|
||||
@@ -1135,6 +1125,7 @@ export function DraftAgentStatusBar({
|
||||
modelOptions={modelOptions}
|
||||
selectedModelId={selectedModel}
|
||||
onSelectModel={(modelId) => onSelectModel(modelId)}
|
||||
onSelectProviderAndModel={onSelectProviderAndModel}
|
||||
isModelLoading={isAllModelsLoading}
|
||||
favoriteKeys={favoriteKeys}
|
||||
onToggleFavoriteModel={(provider, modelId) => {
|
||||
|
||||
@@ -38,7 +38,10 @@ import {
|
||||
import { PlanCard } from "./plan-card";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { PendingPermission } from "@/types/shared";
|
||||
import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types";
|
||||
import type {
|
||||
AgentPermissionAction,
|
||||
AgentPermissionResponse,
|
||||
} from "@server/server/agent/agent-sdk-types";
|
||||
import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
|
||||
@@ -700,6 +703,29 @@ function PermissionRequestCard({
|
||||
const isPlanRequest = request.kind === "plan";
|
||||
const title = isPlanRequest ? "Plan" : (request.title ?? request.name ?? "Permission Required");
|
||||
const description = request.description ?? "";
|
||||
const resolvedActions = useMemo((): AgentPermissionAction[] => {
|
||||
if (request.kind === "question") {
|
||||
return [];
|
||||
}
|
||||
if (Array.isArray(request.actions) && request.actions.length > 0) {
|
||||
return request.actions;
|
||||
}
|
||||
return [
|
||||
{
|
||||
id: "reject",
|
||||
label: "Deny",
|
||||
behavior: "deny",
|
||||
variant: "danger",
|
||||
intent: "dismiss",
|
||||
},
|
||||
{
|
||||
id: "accept",
|
||||
label: isPlanRequest ? "Implement" : "Accept",
|
||||
behavior: "allow",
|
||||
variant: "primary",
|
||||
},
|
||||
];
|
||||
}, [isPlanRequest, request]);
|
||||
|
||||
const planMarkdown = useMemo(() => {
|
||||
if (!request) {
|
||||
@@ -740,11 +766,11 @@ function PermissionRequestCard({
|
||||
isPending: isResponding,
|
||||
} = permissionMutation;
|
||||
|
||||
const [respondingAction, setRespondingAction] = useState<"accept" | "deny" | null>(null);
|
||||
const [respondingActionId, setRespondingActionId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
resetPermissionMutation();
|
||||
setRespondingAction(null);
|
||||
setRespondingActionId(null);
|
||||
}, [permission.request.id, resetPermissionMutation]);
|
||||
const handleResponse = useCallback(
|
||||
(response: AgentPermissionResponse) => {
|
||||
@@ -758,6 +784,24 @@ function PermissionRequestCard({
|
||||
},
|
||||
[permission.agentId, permission.request.id, respondToPermission],
|
||||
);
|
||||
const handleActionPress = useCallback(
|
||||
(action: AgentPermissionAction) => {
|
||||
setRespondingActionId(action.id);
|
||||
if (action.behavior === "allow") {
|
||||
handleResponse({
|
||||
behavior: "allow",
|
||||
selectedActionId: action.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
handleResponse({
|
||||
behavior: "deny",
|
||||
selectedActionId: action.id,
|
||||
message: "Denied by user",
|
||||
});
|
||||
},
|
||||
[handleResponse],
|
||||
);
|
||||
|
||||
if (request.kind === "question") {
|
||||
return (
|
||||
@@ -784,64 +828,48 @@ function PermissionRequestCard({
|
||||
!isMobile && permissionStyles.optionsContainerDesktop,
|
||||
]}
|
||||
>
|
||||
<Pressable
|
||||
testID="permission-request-deny"
|
||||
style={({ pressed, hovered = false }) => [
|
||||
permissionStyles.optionButton,
|
||||
{
|
||||
backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
},
|
||||
pressed ? permissionStyles.optionButtonPressed : null,
|
||||
]}
|
||||
onPress={() => {
|
||||
setRespondingAction("deny");
|
||||
handleResponse({
|
||||
behavior: "deny",
|
||||
message: "Denied by user",
|
||||
});
|
||||
}}
|
||||
disabled={isResponding}
|
||||
>
|
||||
{respondingAction === "deny" ? (
|
||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<View style={permissionStyles.optionContent}>
|
||||
<X size={14} color={theme.colors.foregroundMuted} />
|
||||
<Text style={[permissionStyles.optionText, { color: theme.colors.foregroundMuted }]}>
|
||||
Deny
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
{resolvedActions.map((action) => {
|
||||
const isDanger = action.variant === "danger" || action.behavior === "deny";
|
||||
const isPrimary = action.variant === "primary";
|
||||
const isRespondingAction = respondingActionId === action.id;
|
||||
const textColor = isPrimary ? theme.colors.foreground : theme.colors.foregroundMuted;
|
||||
const iconColor = textColor;
|
||||
const Icon = action.behavior === "allow" ? Check : X;
|
||||
const testID =
|
||||
action.behavior === "deny"
|
||||
? "permission-request-deny"
|
||||
: action.id === "accept" || action.id === "implement"
|
||||
? "permission-request-accept"
|
||||
: `permission-request-action-${action.id}`;
|
||||
|
||||
<Pressable
|
||||
testID="permission-request-accept"
|
||||
style={({ pressed, hovered = false }) => [
|
||||
permissionStyles.optionButton,
|
||||
{
|
||||
backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
},
|
||||
pressed ? permissionStyles.optionButtonPressed : null,
|
||||
]}
|
||||
onPress={() => {
|
||||
setRespondingAction("accept");
|
||||
handleResponse({ behavior: "allow" });
|
||||
}}
|
||||
disabled={isResponding}
|
||||
>
|
||||
{respondingAction === "accept" ? (
|
||||
<ActivityIndicator size="small" color={theme.colors.foreground} />
|
||||
) : (
|
||||
<View style={permissionStyles.optionContent}>
|
||||
<Check size={14} color={theme.colors.foreground} />
|
||||
<Text style={[permissionStyles.optionText, { color: theme.colors.foreground }]}>
|
||||
Accept
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
return (
|
||||
<Pressable
|
||||
key={action.id}
|
||||
testID={testID}
|
||||
style={({ pressed, hovered = false }) => [
|
||||
permissionStyles.optionButton,
|
||||
{
|
||||
backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1,
|
||||
borderColor: isDanger ? theme.colors.borderAccent : theme.colors.borderAccent,
|
||||
},
|
||||
pressed ? permissionStyles.optionButtonPressed : null,
|
||||
]}
|
||||
onPress={() => handleActionPress(action)}
|
||||
disabled={isResponding}
|
||||
>
|
||||
{isRespondingAction ? (
|
||||
<ActivityIndicator size="small" color={textColor} />
|
||||
) : (
|
||||
<View style={permissionStyles.optionContent}>
|
||||
<Icon size={14} color={iconColor} />
|
||||
<Text style={[permissionStyles.optionText, { color: textColor }]}>
|
||||
{action.label}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
DraftAgentStatusBar,
|
||||
type DraftAgentStatusBarProps,
|
||||
} from "./agent-status-bar";
|
||||
import { ContextWindowMeter } from "./context-window-meter";
|
||||
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
@@ -140,6 +141,8 @@ export function Composer({
|
||||
const agent = state.sessions[serverId]?.agents?.get(agentId) ?? null;
|
||||
return {
|
||||
status: agent?.status ?? null,
|
||||
contextWindowMaxTokens: agent?.lastUsage?.contextWindowMaxTokens ?? null,
|
||||
contextWindowUsedTokens: agent?.lastUsage?.contextWindowUsedTokens ?? null,
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -649,6 +652,25 @@ export function Composer({
|
||||
</View>
|
||||
) : null;
|
||||
|
||||
const hasContextWindowMeter =
|
||||
typeof agentState.contextWindowMaxTokens === "number" &&
|
||||
typeof agentState.contextWindowUsedTokens === "number";
|
||||
const contextWindowMaxTokens = hasContextWindowMeter ? agentState.contextWindowMaxTokens : null;
|
||||
const contextWindowUsedTokens = hasContextWindowMeter
|
||||
? agentState.contextWindowUsedTokens
|
||||
: null;
|
||||
|
||||
const beforeVoiceContent = (
|
||||
<View style={styles.contextWindowMeterSlot}>
|
||||
{contextWindowMaxTokens !== null && contextWindowUsedTokens !== null ? (
|
||||
<ContextWindowMeter
|
||||
maxTokens={contextWindowMaxTokens}
|
||||
usedTokens={contextWindowUsedTokens}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
|
||||
const leftContent =
|
||||
resolveStatusControlMode(statusControls) === "draft" && statusControls ? (
|
||||
<DraftAgentStatusBar {...statusControls} />
|
||||
@@ -729,6 +751,7 @@ export function Composer({
|
||||
disabled={isSubmitLoading}
|
||||
isInputActive={isInputActive}
|
||||
leftContent={leftContent}
|
||||
beforeVoiceContent={beforeVoiceContent}
|
||||
rightContent={rightContent}
|
||||
voiceServerId={serverId}
|
||||
voiceAgentId={agentId}
|
||||
@@ -805,6 +828,12 @@ const styles = StyleSheet.create(((theme: Theme) => ({
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
contextWindowMeterSlot: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
realtimeVoiceButton: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import Svg, { Circle } from "react-native-svg";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
type ContextWindowMeterProps = {
|
||||
maxTokens: number;
|
||||
usedTokens: number;
|
||||
};
|
||||
|
||||
const SVG_SIZE = 20;
|
||||
const CENTER = SVG_SIZE / 2;
|
||||
const RADIUS = 7;
|
||||
const STROKE_WIDTH = 2.25;
|
||||
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
|
||||
|
||||
function isValidMaxTokens(value: number): boolean {
|
||||
return Number.isFinite(value) && value > 0;
|
||||
}
|
||||
|
||||
function isValidUsedTokens(value: number): boolean {
|
||||
return Number.isFinite(value) && value >= 0;
|
||||
}
|
||||
|
||||
function getUsagePercentage(maxTokens: number, usedTokens: number): number | null {
|
||||
if (!isValidMaxTokens(maxTokens) || !isValidUsedTokens(usedTokens)) {
|
||||
return null;
|
||||
}
|
||||
return (usedTokens / maxTokens) * 100;
|
||||
}
|
||||
|
||||
function clampPercentage(value: number): number {
|
||||
return Math.max(0, Math.min(100, value));
|
||||
}
|
||||
|
||||
function formatTokenCount(value: number): string {
|
||||
if (value >= 1_000_000) {
|
||||
return `${Math.round(value / 1_000_000)}m`;
|
||||
}
|
||||
if (value >= 1_000) {
|
||||
return `${Math.round(value / 1_000)}k`;
|
||||
}
|
||||
return Math.round(value).toString();
|
||||
}
|
||||
|
||||
function getMeterColors(
|
||||
percentage: number,
|
||||
theme: ReturnType<typeof useUnistyles>["theme"],
|
||||
): { progress: string; track: string } {
|
||||
const track = theme.colors.surface3;
|
||||
if (percentage > 90) {
|
||||
return { progress: theme.colors.destructive, track };
|
||||
}
|
||||
if (percentage >= 70) {
|
||||
return { progress: theme.colors.palette.amber[500], track };
|
||||
}
|
||||
return { progress: theme.colors.foregroundMuted, track };
|
||||
}
|
||||
|
||||
export function ContextWindowMeter({ maxTokens, usedTokens }: ContextWindowMeterProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const percentage = getUsagePercentage(maxTokens, usedTokens);
|
||||
|
||||
if (percentage === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const clampedPercentage = clampPercentage(percentage);
|
||||
const roundedPercentage = Math.round(percentage);
|
||||
const dashOffset = CIRCUMFERENCE - (clampedPercentage / 100) * CIRCUMFERENCE;
|
||||
const colors = getMeterColors(clampedPercentage, theme);
|
||||
|
||||
return (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile>
|
||||
<TooltipTrigger asChild triggerRefProp="ref">
|
||||
<Pressable
|
||||
style={styles.container}
|
||||
accessibilityRole="image"
|
||||
accessibilityLabel={`Context window ${roundedPercentage}% used`}
|
||||
>
|
||||
<Svg
|
||||
width={SVG_SIZE}
|
||||
height={SVG_SIZE}
|
||||
viewBox={`0 0 ${SVG_SIZE} ${SVG_SIZE}`}
|
||||
style={styles.svg}
|
||||
accessibilityElementsHidden
|
||||
importantForAccessibility="no-hide-descendants"
|
||||
>
|
||||
<Circle
|
||||
cx={CENTER}
|
||||
cy={CENTER}
|
||||
r={RADIUS}
|
||||
fill="none"
|
||||
stroke={colors.track}
|
||||
strokeWidth={STROKE_WIDTH}
|
||||
/>
|
||||
<Circle
|
||||
cx={CENTER}
|
||||
cy={CENTER}
|
||||
r={RADIUS}
|
||||
fill="none"
|
||||
stroke={colors.progress}
|
||||
strokeWidth={STROKE_WIDTH}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={CIRCUMFERENCE}
|
||||
strokeDashoffset={dashOffset}
|
||||
/>
|
||||
</Svg>
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<View style={styles.tooltipContent}>
|
||||
<Text style={styles.tooltipTitle}>Context window</Text>
|
||||
<Text style={styles.tooltipText}>{`${roundedPercentage}% used`}</Text>
|
||||
<Text
|
||||
style={styles.tooltipDetail}
|
||||
>{`${formatTokenCount(usedTokens)} / ${formatTokenCount(maxTokens)} tokens`}</Text>
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
svg: {
|
||||
transform: [{ rotate: "-90deg" }],
|
||||
},
|
||||
tooltipContent: {
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
tooltipTitle: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
},
|
||||
tooltipText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: theme.fontSize.sm * 1.4,
|
||||
},
|
||||
tooltipDetail: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
lineHeight: theme.fontSize.xs * 1.4,
|
||||
},
|
||||
}));
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
@@ -118,7 +118,6 @@ export function FileExplorerPane({
|
||||
);
|
||||
|
||||
const {
|
||||
workspaceStateKey: actionsWorkspaceStateKey,
|
||||
requestDirectoryListing,
|
||||
requestFileDownloadToken,
|
||||
selectExplorerEntry,
|
||||
@@ -129,6 +128,16 @@ export function FileExplorerPane({
|
||||
});
|
||||
const sortOption = usePanelStore((state) => state.explorerSortOption);
|
||||
const setSortOption = usePanelStore((state) => state.setExplorerSortOption);
|
||||
const expandedPathsArray = usePanelStore((state) =>
|
||||
workspaceStateKey ? state.expandedPathsByWorkspace[workspaceStateKey] : undefined,
|
||||
);
|
||||
const setExpandedPathsForWorkspace = usePanelStore(
|
||||
(state) => state.setExpandedPathsForWorkspace,
|
||||
);
|
||||
const expandedPaths = useMemo(
|
||||
() => new Set(expandedPathsArray && expandedPathsArray.length > 0 ? expandedPathsArray : ["."]),
|
||||
[expandedPathsArray],
|
||||
);
|
||||
|
||||
const directories = explorerState?.directories ?? new Map();
|
||||
const pendingRequest = explorerState?.pendingRequest ?? null;
|
||||
@@ -144,7 +153,6 @@ export function FileExplorerPane({
|
||||
[isExplorerLoading, pendingRequest?.mode, pendingRequest?.path],
|
||||
);
|
||||
|
||||
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => new Set(["."]));
|
||||
const treeListRef = useRef<FlatList<TreeRow>>(null);
|
||||
const scrollbar = useWebScrollViewScrollbar(treeListRef, {
|
||||
enabled: showDesktopWebScrollbar,
|
||||
@@ -154,8 +162,7 @@ export function FileExplorerPane({
|
||||
|
||||
useEffect(() => {
|
||||
hasInitializedRef.current = false;
|
||||
setExpandedPaths(new Set(["."]));
|
||||
}, [actionsWorkspaceStateKey]);
|
||||
}, [workspaceStateKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasWorkspaceScope) {
|
||||
@@ -169,23 +176,35 @@ export function FileExplorerPane({
|
||||
recordHistory: false,
|
||||
setCurrentPath: false,
|
||||
});
|
||||
}, [hasWorkspaceScope, requestDirectoryListing]);
|
||||
const persistedPaths = usePanelStore.getState().expandedPathsByWorkspace[workspaceStateKey ?? ""];
|
||||
if (persistedPaths) {
|
||||
for (const path of persistedPaths) {
|
||||
if (path !== ".") {
|
||||
void requestDirectoryListing(path, {
|
||||
recordHistory: false,
|
||||
setCurrentPath: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [hasWorkspaceScope, requestDirectoryListing, workspaceStateKey]);
|
||||
|
||||
// Expand ancestor directories when a file is selected (e.g., from an inline path click)
|
||||
useEffect(() => {
|
||||
if (!selectedEntryPath || !hasWorkspaceScope) {
|
||||
if (!selectedEntryPath || !workspaceStateKey) {
|
||||
return;
|
||||
}
|
||||
const parentDir = getParentDirectory(selectedEntryPath);
|
||||
const ancestors = getAncestorDirectories(parentDir);
|
||||
|
||||
setExpandedPaths((prev) => {
|
||||
const next = new Set(prev);
|
||||
ancestors.forEach((path) => next.add(path));
|
||||
return next;
|
||||
});
|
||||
|
||||
ancestors.forEach((path) => {
|
||||
const newPaths = ancestors.filter((path) => !expandedPaths.has(path));
|
||||
if (newPaths.length === 0) {
|
||||
return;
|
||||
}
|
||||
setExpandedPathsForWorkspace(
|
||||
workspaceStateKey,
|
||||
[...Array.from(expandedPaths), ...newPaths],
|
||||
);
|
||||
newPaths.forEach((path) => {
|
||||
if (!directories.has(path)) {
|
||||
void requestDirectoryListing(path, {
|
||||
recordHistory: false,
|
||||
@@ -193,34 +212,46 @@ export function FileExplorerPane({
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [directories, hasWorkspaceScope, requestDirectoryListing, selectedEntryPath]);
|
||||
}, [
|
||||
directories,
|
||||
workspaceStateKey,
|
||||
expandedPaths,
|
||||
requestDirectoryListing,
|
||||
selectedEntryPath,
|
||||
setExpandedPathsForWorkspace,
|
||||
]);
|
||||
|
||||
const handleToggleDirectory = useCallback(
|
||||
(entry: ExplorerEntry) => {
|
||||
if (!hasWorkspaceScope) {
|
||||
if (!workspaceStateKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isExpanded = expandedPaths.has(entry.path);
|
||||
const nextExpanded = !isExpanded;
|
||||
setExpandedPaths((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (isExpanded) {
|
||||
next.delete(entry.path);
|
||||
} else {
|
||||
next.add(entry.path);
|
||||
if (isExpanded) {
|
||||
setExpandedPathsForWorkspace(
|
||||
workspaceStateKey,
|
||||
Array.from(expandedPaths).filter((path) => path !== entry.path),
|
||||
);
|
||||
} else {
|
||||
setExpandedPathsForWorkspace(
|
||||
workspaceStateKey,
|
||||
[...Array.from(expandedPaths), entry.path],
|
||||
);
|
||||
if (!directories.has(entry.path)) {
|
||||
void requestDirectoryListing(entry.path, {
|
||||
recordHistory: false,
|
||||
setCurrentPath: false,
|
||||
});
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
if (nextExpanded && !directories.has(entry.path)) {
|
||||
void requestDirectoryListing(entry.path, {
|
||||
recordHistory: false,
|
||||
setCurrentPath: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
[directories, expandedPaths, hasWorkspaceScope, requestDirectoryListing],
|
||||
[
|
||||
workspaceStateKey,
|
||||
expandedPaths,
|
||||
directories,
|
||||
requestDirectoryListing,
|
||||
setExpandedPathsForWorkspace,
|
||||
],
|
||||
);
|
||||
|
||||
const handleOpenFile = useCallback(
|
||||
|
||||
@@ -18,17 +18,21 @@ import {
|
||||
type LayoutChangeEvent,
|
||||
type NativeSyntheticEvent,
|
||||
type NativeScrollEvent,
|
||||
TextStyle,
|
||||
} from "react-native";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
AlignJustify,
|
||||
Archive,
|
||||
ChevronDown,
|
||||
Columns2,
|
||||
GitBranch,
|
||||
GitCommitHorizontal,
|
||||
GitMerge,
|
||||
ListChevronsDownUp,
|
||||
ListChevronsUpDown,
|
||||
Pilcrow,
|
||||
RefreshCcw,
|
||||
Upload,
|
||||
WrapText,
|
||||
@@ -42,6 +46,7 @@ import {
|
||||
} from "@/hooks/use-checkout-diff-query";
|
||||
import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query";
|
||||
import { useCheckoutPrStatusQuery } from "@/hooks/use-checkout-pr-status-query";
|
||||
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
|
||||
import { DiffScroll } from "./diff-scroll";
|
||||
import {
|
||||
darkHighlightColors,
|
||||
@@ -51,6 +56,7 @@ import {
|
||||
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import { shouldAnchorHeaderBeforeCollapse } from "@/utils/git-diff-scroll";
|
||||
import { buildSplitDiffRows, type SplitDiffDisplayLine } from "@/utils/diff-layout";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -66,6 +72,8 @@ import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
|
||||
import { buildNewAgentRoute, resolveNewAgentWorkingDir } from "@/utils/new-agent-routing";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { GitActionsSplitButton } from "@/components/git-actions-split-button";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
|
||||
|
||||
export type { GitActionId, GitAction, GitActions } from "@/components/git-actions-policy";
|
||||
|
||||
@@ -77,13 +85,27 @@ type HighlightStyle = NonNullable<HighlightToken["style"]>;
|
||||
|
||||
interface HighlightedTextProps {
|
||||
tokens: HighlightToken[];
|
||||
baseStyle: HighlightStyle | null;
|
||||
lineType: "add" | "remove" | "context" | "header";
|
||||
wrapLines?: boolean;
|
||||
}
|
||||
|
||||
function HighlightedText({ tokens, lineType }: HighlightedTextProps) {
|
||||
type WrappedWebTextStyle = TextStyle & {
|
||||
whiteSpace?: "pre" | "pre-wrap";
|
||||
overflowWrap?: "normal" | "anywhere";
|
||||
};
|
||||
|
||||
function getWrappedTextStyle(wrapLines: boolean): WrappedWebTextStyle | undefined {
|
||||
if (Platform.OS !== "web") {
|
||||
return undefined;
|
||||
}
|
||||
return wrapLines
|
||||
? { whiteSpace: "pre-wrap", overflowWrap: "anywhere" }
|
||||
: { whiteSpace: "pre", overflowWrap: "normal" };
|
||||
}
|
||||
|
||||
function HighlightedText({ tokens, wrapLines = false }: HighlightedTextProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isDark = theme.colorScheme === "dark";
|
||||
const lineHeight = theme.lineHeight.diff;
|
||||
|
||||
const getTokenColor = (style: HighlightStyle | null): string => {
|
||||
const baseColor = isDark ? "#c9d1d9" : "#24292f";
|
||||
@@ -93,9 +115,14 @@ function HighlightedText({ tokens, lineType }: HighlightedTextProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<Text style={styles.diffLineText}>
|
||||
<Text
|
||||
style={[
|
||||
styles.diffLineText,
|
||||
{ lineHeight, ...getWrappedTextStyle(wrapLines) },
|
||||
]}
|
||||
>
|
||||
{tokens.map((token, index) => (
|
||||
<Text key={index} style={{ color: getTokenColor(token.style) }}>
|
||||
<Text key={index} style={{ color: getTokenColor(token.style), lineHeight }}>
|
||||
{token.text}
|
||||
</Text>
|
||||
))}
|
||||
@@ -115,10 +142,12 @@ function DiffLineView({
|
||||
line,
|
||||
lineNumber,
|
||||
gutterWidth,
|
||||
wrapLines,
|
||||
}: {
|
||||
line: DiffLine;
|
||||
lineNumber: number | null;
|
||||
gutterWidth: number;
|
||||
wrapLines: boolean;
|
||||
}) {
|
||||
return (
|
||||
<View
|
||||
@@ -142,11 +171,12 @@ function DiffLineView({
|
||||
</Text>
|
||||
</View>
|
||||
{line.tokens && line.type !== "header" ? (
|
||||
<HighlightedText tokens={line.tokens} baseStyle={null} lineType={line.type} />
|
||||
<HighlightedText tokens={line.tokens} wrapLines={wrapLines} />
|
||||
) : (
|
||||
<Text
|
||||
style={[
|
||||
styles.diffLineText,
|
||||
getWrappedTextStyle(wrapLines),
|
||||
line.type === "add" && styles.addLineText,
|
||||
line.type === "remove" && styles.removeLineText,
|
||||
line.type === "header" && styles.headerLineText,
|
||||
@@ -160,6 +190,59 @@ function DiffLineView({
|
||||
);
|
||||
}
|
||||
|
||||
function SplitDiffCell({
|
||||
line,
|
||||
gutterWidth,
|
||||
wrapLines,
|
||||
showDivider = false,
|
||||
}: {
|
||||
line: SplitDiffDisplayLine | null;
|
||||
gutterWidth: number;
|
||||
wrapLines: boolean;
|
||||
showDivider?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.splitCell,
|
||||
showDivider && styles.splitCellWithDivider,
|
||||
!line && styles.emptySplitCell,
|
||||
line?.type === "add" && styles.addLineContainer,
|
||||
line?.type === "remove" && styles.removeLineContainer,
|
||||
line?.type === "context" && styles.contextLineContainer,
|
||||
]}
|
||||
>
|
||||
<View style={[styles.lineNumberGutter, { width: gutterWidth }]}>
|
||||
<Text
|
||||
style={[
|
||||
styles.lineNumberText,
|
||||
line?.type === "add" && styles.addLineNumberText,
|
||||
line?.type === "remove" && styles.removeLineNumberText,
|
||||
]}
|
||||
>
|
||||
{line?.lineNumber != null ? String(line.lineNumber) : ""}
|
||||
</Text>
|
||||
</View>
|
||||
{line?.tokens ? (
|
||||
<HighlightedText tokens={line.tokens} wrapLines={wrapLines} />
|
||||
) : (
|
||||
<Text
|
||||
style={[
|
||||
styles.diffLineText,
|
||||
getWrappedTextStyle(wrapLines) ,
|
||||
line?.type === "add" && styles.addLineText,
|
||||
line?.type === "remove" && styles.removeLineText,
|
||||
line?.type === "context" && styles.contextLineText,
|
||||
!line && styles.emptySplitCellText,
|
||||
]}
|
||||
>
|
||||
{line?.content ?? ""}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const DiffFileHeader = memo(function DiffFileHeader({
|
||||
file,
|
||||
isExpanded,
|
||||
@@ -245,21 +328,25 @@ const DiffFileHeader = memo(function DiffFileHeader({
|
||||
|
||||
function DiffFileBody({
|
||||
file,
|
||||
layout,
|
||||
wrapLines,
|
||||
onBodyHeightChange,
|
||||
testID,
|
||||
}: {
|
||||
file: ParsedDiffFile;
|
||||
layout: "unified" | "split";
|
||||
wrapLines: boolean;
|
||||
onBodyHeightChange?: (path: string, height: number) => void;
|
||||
testID?: string;
|
||||
}) {
|
||||
const [scrollViewWidth, setScrollViewWidth] = useState(0);
|
||||
const [bodyWidth, setBodyWidth] = useState(0);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[styles.fileSectionBodyContainer, styles.fileSectionBorder]}
|
||||
onLayout={(event) => {
|
||||
setBodyWidth(event.nativeEvent.layout.width);
|
||||
onBodyHeightChange?.(file.path, event.nativeEvent.layout.height);
|
||||
}}
|
||||
testID={testID}
|
||||
@@ -275,50 +362,80 @@ function DiffFileBody({
|
||||
);
|
||||
}
|
||||
|
||||
const linesContent = (() => {
|
||||
let maxLineNo = 0;
|
||||
for (const hunk of file.hunks) {
|
||||
maxLineNo = Math.max(
|
||||
maxLineNo,
|
||||
hunk.oldStart + hunk.oldCount,
|
||||
hunk.newStart + hunk.newCount,
|
||||
);
|
||||
}
|
||||
const gutterWidth = lineNumberGutterWidth(maxLineNo);
|
||||
return file.hunks.map((hunk, hunkIndex) => {
|
||||
let oldLineNo = hunk.oldStart;
|
||||
let newLineNo = hunk.newStart;
|
||||
return hunk.lines.map((line, lineIndex) => {
|
||||
let lineNumber: number | null = null;
|
||||
if (line.type === "remove") {
|
||||
lineNumber = oldLineNo;
|
||||
oldLineNo++;
|
||||
} else if (line.type === "add") {
|
||||
lineNumber = newLineNo;
|
||||
newLineNo++;
|
||||
} else if (line.type === "context") {
|
||||
lineNumber = newLineNo;
|
||||
oldLineNo++;
|
||||
newLineNo++;
|
||||
}
|
||||
return (
|
||||
<DiffLineView
|
||||
key={`${hunkIndex}-${lineIndex}`}
|
||||
line={line}
|
||||
lineNumber={lineNumber}
|
||||
gutterWidth={gutterWidth}
|
||||
/>
|
||||
);
|
||||
});
|
||||
});
|
||||
})();
|
||||
let maxLineNo = 0;
|
||||
for (const hunk of file.hunks) {
|
||||
maxLineNo = Math.max(maxLineNo, hunk.oldStart + hunk.oldCount, hunk.newStart + hunk.newCount);
|
||||
}
|
||||
const gutterWidth = lineNumberGutterWidth(maxLineNo);
|
||||
|
||||
const linesContent =
|
||||
layout === "split"
|
||||
? buildSplitDiffRows(file).map((row, rowIndex) => {
|
||||
if (row.kind === "header") {
|
||||
return (
|
||||
<View key={`header-${rowIndex}`} style={styles.splitHeaderRow}>
|
||||
<Text style={[styles.diffLineText, styles.headerLineText]}>{row.content}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View key={`pair-${rowIndex}`} style={styles.splitRow}>
|
||||
<SplitDiffCell line={row.left} gutterWidth={gutterWidth} wrapLines={wrapLines} />
|
||||
<SplitDiffCell
|
||||
line={row.right}
|
||||
gutterWidth={gutterWidth}
|
||||
wrapLines={wrapLines}
|
||||
showDivider
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
})
|
||||
: file.hunks.map((hunk, hunkIndex) => {
|
||||
let oldLineNo = hunk.oldStart;
|
||||
let newLineNo = hunk.newStart;
|
||||
return hunk.lines.map((line, lineIndex) => {
|
||||
let lineNumber: number | null = null;
|
||||
if (line.type === "remove") {
|
||||
lineNumber = oldLineNo;
|
||||
oldLineNo++;
|
||||
} else if (line.type === "add") {
|
||||
lineNumber = newLineNo;
|
||||
newLineNo++;
|
||||
} else if (line.type === "context") {
|
||||
lineNumber = newLineNo;
|
||||
oldLineNo++;
|
||||
newLineNo++;
|
||||
}
|
||||
return (
|
||||
<DiffLineView
|
||||
key={`${hunkIndex}-${lineIndex}`}
|
||||
line={line}
|
||||
lineNumber={lineNumber}
|
||||
gutterWidth={gutterWidth}
|
||||
wrapLines={wrapLines}
|
||||
/>
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const availableWidth = bodyWidth > 0 ? bodyWidth : scrollViewWidth;
|
||||
const contentContainer = (
|
||||
<View
|
||||
style={[
|
||||
layout === "split" ? styles.splitLinesContainer : styles.linesContainer,
|
||||
availableWidth > 0 &&
|
||||
(layout === "split"
|
||||
? { width: availableWidth, minWidth: availableWidth, maxWidth: availableWidth }
|
||||
: { minWidth: availableWidth }),
|
||||
]}
|
||||
>
|
||||
{linesContent}
|
||||
</View>
|
||||
);
|
||||
|
||||
if (wrapLines) {
|
||||
return (
|
||||
<View style={styles.diffContent}>
|
||||
<View style={styles.linesContainer}>{linesContent}</View>
|
||||
</View>
|
||||
);
|
||||
return <View style={styles.diffContent}>{contentContainer}</View>;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -328,11 +445,7 @@ function DiffFileBody({
|
||||
style={styles.diffContent}
|
||||
contentContainerStyle={styles.diffContentInner}
|
||||
>
|
||||
<View
|
||||
style={[styles.linesContainer, scrollViewWidth > 0 && { minWidth: scrollViewWidth }]}
|
||||
>
|
||||
{linesContent}
|
||||
</View>
|
||||
{contentContainer}
|
||||
</DiffScroll>
|
||||
);
|
||||
})()}
|
||||
@@ -355,26 +468,31 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
const { theme } = useUnistyles();
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
|
||||
const canUseSplitLayout = Platform.OS === "web" && !isMobile;
|
||||
const router = useRouter();
|
||||
const [diffModeOverride, setDiffModeOverride] = useState<"uncommitted" | "base" | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false);
|
||||
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge");
|
||||
const [wrapLines, setWrapLines] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
AsyncStorage.getItem("diff-wrap-lines").then((value) => {
|
||||
if (value === "true") setWrapLines(true);
|
||||
});
|
||||
}, []);
|
||||
const { preferences: changesPreferences, updatePreferences: updateChangesPreferences } =
|
||||
useChangesPreferences();
|
||||
const wrapLines = changesPreferences.wrapLines;
|
||||
const effectiveLayout = canUseSplitLayout ? changesPreferences.layout : "unified";
|
||||
|
||||
const handleToggleWrapLines = useCallback(() => {
|
||||
setWrapLines((prev) => {
|
||||
const next = !prev;
|
||||
AsyncStorage.setItem("diff-wrap-lines", String(next));
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
void updateChangesPreferences({ wrapLines: !wrapLines });
|
||||
}, [updateChangesPreferences, wrapLines]);
|
||||
|
||||
const handleLayoutChange = useCallback(
|
||||
(nextLayout: "unified" | "split") => {
|
||||
void updateChangesPreferences({ layout: nextLayout });
|
||||
},
|
||||
[updateChangesPreferences],
|
||||
);
|
||||
|
||||
const handleToggleHideWhitespace = useCallback(() => {
|
||||
void updateChangesPreferences({ hideWhitespace: !changesPreferences.hideWhitespace });
|
||||
}, [changesPreferences.hideWhitespace, updateChangesPreferences]);
|
||||
|
||||
const {
|
||||
status,
|
||||
@@ -410,6 +528,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
cwd,
|
||||
mode: diffMode,
|
||||
baseRef,
|
||||
ignoreWhitespace: changesPreferences.hideWhitespace,
|
||||
enabled: isGit,
|
||||
});
|
||||
const {
|
||||
@@ -424,7 +543,25 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
});
|
||||
// Track user-initiated refresh to avoid iOS RefreshControl animation on background fetches
|
||||
const [isManualRefresh, setIsManualRefresh] = useState(false);
|
||||
const [expandedByPath, setExpandedByPath] = useState<Record<string, boolean>>({});
|
||||
const normalizedWorkspaceRoot = useMemo(() => cwd.trim(), [cwd]);
|
||||
const workspaceStateKey = useMemo(
|
||||
() =>
|
||||
buildWorkspaceExplorerStateKey({
|
||||
workspaceId,
|
||||
workspaceRoot: normalizedWorkspaceRoot,
|
||||
}),
|
||||
[normalizedWorkspaceRoot, workspaceId],
|
||||
);
|
||||
const expandedPathsArray = usePanelStore((state) =>
|
||||
workspaceStateKey ? state.diffExpandedPathsByWorkspace[workspaceStateKey] : undefined,
|
||||
);
|
||||
const setDiffExpandedPathsForWorkspace = usePanelStore(
|
||||
(state) => state.setDiffExpandedPathsForWorkspace,
|
||||
);
|
||||
const expandedPaths = useMemo(
|
||||
() => new Set(expandedPathsArray ?? []),
|
||||
[expandedPathsArray],
|
||||
);
|
||||
const diffListRef = useRef<FlatList<DiffFlatItem>>(null);
|
||||
const scrollbar = useWebScrollViewScrollbar(diffListRef, {
|
||||
enabled: showDesktopWebScrollbar,
|
||||
@@ -484,7 +621,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
const stickyIndices: number[] = [];
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
const isExpanded = expandedByPath[file.path] ?? false;
|
||||
const isExpanded = expandedPaths.has(file.path);
|
||||
items.push({ type: "header", file, fileIndex: i, isExpanded });
|
||||
if (isExpanded) {
|
||||
stickyIndices.push(items.length - 1);
|
||||
@@ -494,7 +631,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
}
|
||||
}
|
||||
return { flatItems: items, stickyHeaderIndices: stickyIndices };
|
||||
}, [files, expandedByPath]);
|
||||
}, [expandedPaths, files]);
|
||||
|
||||
const handleHeaderHeightChange = useCallback((path: string, height: number) => {
|
||||
if (!Number.isFinite(height) || height <= 0) {
|
||||
@@ -540,18 +677,21 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
break;
|
||||
}
|
||||
offset += headerHeightByPathRef.current[file.path] ?? defaultHeaderHeight;
|
||||
if (expandedByPath[file.path]) {
|
||||
if (expandedPaths.has(file.path)) {
|
||||
offset += bodyHeightByPathRef.current[file.path] ?? 0;
|
||||
}
|
||||
}
|
||||
return Math.max(0, offset);
|
||||
},
|
||||
[expandedByPath, files],
|
||||
[expandedPaths, files],
|
||||
);
|
||||
|
||||
const handleToggleExpanded = useCallback(
|
||||
(path: string) => {
|
||||
const isCurrentlyExpanded = expandedByPath[path] ?? false;
|
||||
if (!workspaceStateKey) {
|
||||
return;
|
||||
}
|
||||
const isCurrentlyExpanded = expandedPaths.has(path);
|
||||
const nextExpanded = !isCurrentlyExpanded;
|
||||
const targetOffset = isCurrentlyExpanded ? computeHeaderOffset(path) : null;
|
||||
const headerHeight = headerHeightByPathRef.current[path] ?? defaultHeaderHeightRef.current;
|
||||
@@ -573,32 +713,32 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
});
|
||||
}
|
||||
|
||||
setExpandedByPath((prev) => ({
|
||||
...prev,
|
||||
// Use a deterministic target value (instead of toggling from prev) so duplicate
|
||||
// onPress events from sticky headers on Android can't flip back immediately.
|
||||
[path]: nextExpanded,
|
||||
}));
|
||||
const nextPaths = nextExpanded
|
||||
? [...expandedPaths, path]
|
||||
: Array.from(expandedPaths).filter((expandedPath) => expandedPath !== path);
|
||||
setDiffExpandedPathsForWorkspace(workspaceStateKey, nextPaths);
|
||||
},
|
||||
[computeHeaderOffset, expandedByPath],
|
||||
[computeHeaderOffset, expandedPaths, setDiffExpandedPathsForWorkspace, workspaceStateKey],
|
||||
);
|
||||
|
||||
const allExpanded = useMemo(() => {
|
||||
if (files.length === 0) return false;
|
||||
return files.every((file) => expandedByPath[file.path]);
|
||||
}, [files, expandedByPath]);
|
||||
return files.every((file) => expandedPaths.has(file.path));
|
||||
}, [expandedPaths, files]);
|
||||
|
||||
const handleToggleExpandAll = useCallback(() => {
|
||||
if (allExpanded) {
|
||||
setExpandedByPath({});
|
||||
} else {
|
||||
const newExpanded: Record<string, boolean> = {};
|
||||
for (const file of files) {
|
||||
newExpanded[file.path] = true;
|
||||
}
|
||||
setExpandedByPath(newExpanded);
|
||||
if (!workspaceStateKey) {
|
||||
return;
|
||||
}
|
||||
}, [allExpanded, files]);
|
||||
if (allExpanded) {
|
||||
setDiffExpandedPathsForWorkspace(workspaceStateKey, []);
|
||||
} else {
|
||||
setDiffExpandedPathsForWorkspace(
|
||||
workspaceStateKey,
|
||||
files.map((file) => file.path),
|
||||
);
|
||||
}
|
||||
}, [allExpanded, files, setDiffExpandedPathsForWorkspace, workspaceStateKey]);
|
||||
|
||||
// Reset manual refresh flag when fetch completes
|
||||
useEffect(() => {
|
||||
@@ -726,13 +866,20 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
return (
|
||||
<DiffFileBody
|
||||
file={item.file}
|
||||
layout={effectiveLayout}
|
||||
wrapLines={wrapLines}
|
||||
onBodyHeightChange={handleBodyHeightChange}
|
||||
testID={`diff-file-${item.fileIndex}-body`}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[handleBodyHeightChange, handleHeaderHeightChange, handleToggleExpanded, wrapLines],
|
||||
[
|
||||
effectiveLayout,
|
||||
handleBodyHeightChange,
|
||||
handleHeaderHeightChange,
|
||||
handleToggleExpanded,
|
||||
wrapLines,
|
||||
],
|
||||
);
|
||||
|
||||
const flatKeyExtractor = useCallback(
|
||||
@@ -821,7 +968,11 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
bodyContent = (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text style={styles.emptyText}>
|
||||
{diffMode === "uncommitted" ? "No uncommitted changes" : `No changes vs ${baseRefLabel}`}
|
||||
{changesPreferences.hideWhitespace
|
||||
? "No visible changes after hiding whitespace"
|
||||
: diffMode === "uncommitted"
|
||||
? "No uncommitted changes"
|
||||
: `No changes vs ${baseRefLabel}`}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
@@ -833,7 +984,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
renderItem={renderFlatItem}
|
||||
keyExtractor={flatKeyExtractor}
|
||||
stickyHeaderIndices={stickyHeaderIndices}
|
||||
extraData={expandedByPath}
|
||||
extraData={{ expandedPathsArray, effectiveLayout, wrapLines }}
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.contentContainer}
|
||||
testID="git-diff-scroll"
|
||||
@@ -1019,56 +1170,149 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{files.length > 0 ? (
|
||||
<View style={styles.diffStatusButtons}>
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.expandAllButton,
|
||||
(hovered || pressed) && styles.diffStatusRowHovered,
|
||||
]}
|
||||
onPress={handleToggleWrapLines}
|
||||
>
|
||||
<WrapText size={isMobile ? 18 : 14} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<Text style={styles.tooltipText}>
|
||||
{wrapLines ? "Scroll long lines" : "Wrap long lines"}
|
||||
</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.expandAllButton,
|
||||
(hovered || pressed) && styles.diffStatusRowHovered,
|
||||
]}
|
||||
onPress={handleToggleExpandAll}
|
||||
>
|
||||
{allExpanded ? (
|
||||
<ListChevronsDownUp
|
||||
size={isMobile ? 18 : 14}
|
||||
color={theme.colors.foregroundMuted}
|
||||
<View style={styles.diffStatusButtons}>
|
||||
{canUseSplitLayout ? (
|
||||
<View style={styles.toggleButtonGroup}>
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Unified diff"
|
||||
testID="changes-layout-unified"
|
||||
onPress={() => handleLayoutChange("unified")}
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.toggleButton,
|
||||
styles.toggleButtonGroupStart,
|
||||
changesPreferences.layout === "unified" && styles.toggleButtonSelected,
|
||||
(hovered || pressed) && styles.diffStatusRowHovered,
|
||||
]}
|
||||
>
|
||||
<AlignJustify
|
||||
size={14}
|
||||
color={
|
||||
changesPreferences.layout === "unified"
|
||||
? theme.colors.foreground
|
||||
: theme.colors.foregroundMuted
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ListChevronsUpDown
|
||||
size={isMobile ? 18 : 14}
|
||||
color={theme.colors.foregroundMuted}
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<Text style={styles.tooltipText}>Unified diff</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Side-by-side diff"
|
||||
testID="changes-layout-split"
|
||||
onPress={() => handleLayoutChange("split")}
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.toggleButton,
|
||||
styles.toggleButtonGroupEnd,
|
||||
changesPreferences.layout === "split" && styles.toggleButtonSelected,
|
||||
(hovered || pressed) && styles.diffStatusRowHovered,
|
||||
]}
|
||||
>
|
||||
<Columns2
|
||||
size={14}
|
||||
color={
|
||||
changesPreferences.layout === "split"
|
||||
? theme.colors.foreground
|
||||
: theme.colors.foregroundMuted
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<Text style={styles.tooltipText}>
|
||||
{allExpanded ? "Collapse all files" : "Expand all files"}
|
||||
</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</View>
|
||||
) : null}
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<Text style={styles.tooltipText}>Side-by-side diff</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</View>
|
||||
) : null}
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Hide whitespace"
|
||||
testID="changes-toggle-whitespace"
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.expandAllButton,
|
||||
changesPreferences.hideWhitespace && styles.toggleButtonSelected,
|
||||
(hovered || pressed) && styles.diffStatusRowHovered,
|
||||
]}
|
||||
onPress={handleToggleHideWhitespace}
|
||||
>
|
||||
<Pilcrow
|
||||
size={isMobile ? 18 : 14}
|
||||
color={
|
||||
changesPreferences.hideWhitespace
|
||||
? theme.colors.foreground
|
||||
: theme.colors.foregroundMuted
|
||||
}
|
||||
/>
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<Text style={styles.tooltipText}>Hide whitespace</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{files.length > 0 ? (
|
||||
<View style={styles.diffStatusButtons}>
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.expandAllButton,
|
||||
wrapLines && styles.toggleButtonSelected,
|
||||
(hovered || pressed) && styles.diffStatusRowHovered,
|
||||
]}
|
||||
onPress={handleToggleWrapLines}
|
||||
>
|
||||
<WrapText
|
||||
size={isMobile ? 18 : 14}
|
||||
color={wrapLines ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<Text style={styles.tooltipText}>
|
||||
{wrapLines ? "Scroll long lines" : "Wrap long lines"}
|
||||
</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.expandAllButton,
|
||||
(hovered || pressed) && styles.diffStatusRowHovered,
|
||||
]}
|
||||
onPress={handleToggleExpandAll}
|
||||
>
|
||||
{allExpanded ? (
|
||||
<ListChevronsDownUp
|
||||
size={isMobile ? 18 : 14}
|
||||
color={theme.colors.foregroundMuted}
|
||||
/>
|
||||
) : (
|
||||
<ListChevronsUpDown
|
||||
size={isMobile ? 18 : 14}
|
||||
color={theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<Text style={styles.tooltipText}>
|
||||
{allExpanded ? "Collapse all files" : "Expand all files"}
|
||||
</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
@@ -1160,11 +1404,42 @@ const styles = StyleSheet.create((theme) => ({
|
||||
diffStatusButtons: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: {
|
||||
xs: theme.spacing[1],
|
||||
sm: theme.spacing[1],
|
||||
md: 0,
|
||||
gap: theme.spacing[1],
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
toggleButtonGroup: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
},
|
||||
toggleButton: {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minWidth: {
|
||||
xs: 32,
|
||||
sm: 32,
|
||||
md: 24,
|
||||
},
|
||||
height: {
|
||||
xs: 32,
|
||||
sm: 32,
|
||||
md: 24,
|
||||
},
|
||||
paddingHorizontal: {
|
||||
xs: theme.spacing[2],
|
||||
sm: theme.spacing[2],
|
||||
md: theme.spacing[1],
|
||||
},
|
||||
},
|
||||
toggleButtonGroupStart: {
|
||||
borderTopLeftRadius: theme.borderRadius.base,
|
||||
borderBottomLeftRadius: theme.borderRadius.base,
|
||||
},
|
||||
toggleButtonGroupEnd: {
|
||||
borderTopRightRadius: theme.borderRadius.base,
|
||||
borderBottomRightRadius: theme.borderRadius.base,
|
||||
},
|
||||
toggleButtonSelected: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
expandAllButton: {
|
||||
flexDirection: "row",
|
||||
@@ -1343,6 +1618,36 @@ const styles = StyleSheet.create((theme) => ({
|
||||
linesContainer: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
splitLinesContainer: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
minWidth: 760,
|
||||
},
|
||||
splitRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "stretch",
|
||||
width: "100%",
|
||||
minWidth: "100%",
|
||||
},
|
||||
splitHeaderRow: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
},
|
||||
splitCell: {
|
||||
flex: 1,
|
||||
flexBasis: 0,
|
||||
minWidth: 0,
|
||||
flexDirection: "row",
|
||||
alignItems: "stretch",
|
||||
overflow: "hidden",
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
emptySplitCell: {
|
||||
backgroundColor: theme.colors.surfaceDiffEmpty,
|
||||
},
|
||||
splitCellWithDivider: {
|
||||
borderLeftWidth: theme.borderWidth[1],
|
||||
borderLeftColor: theme.colors.border,
|
||||
},
|
||||
diffLineContainer: {
|
||||
flexDirection: "row",
|
||||
alignItems: "stretch",
|
||||
@@ -1352,13 +1657,13 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderRightColor: theme.colors.border,
|
||||
marginRight: theme.spacing[2],
|
||||
alignSelf: "stretch",
|
||||
justifyContent: "center",
|
||||
justifyContent: "flex-start",
|
||||
},
|
||||
lineNumberText: {
|
||||
textAlign: "right",
|
||||
paddingRight: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1],
|
||||
fontSize: theme.fontSize.xs,
|
||||
lineHeight: theme.lineHeight.diff,
|
||||
fontFamily: Fonts.mono,
|
||||
color: theme.colors.foregroundMuted,
|
||||
userSelect: "none",
|
||||
@@ -1372,8 +1677,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
diffLineText: {
|
||||
flex: 1,
|
||||
paddingRight: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[1],
|
||||
fontSize: theme.fontSize.xs,
|
||||
lineHeight: theme.lineHeight.diff,
|
||||
fontFamily: Fonts.mono,
|
||||
color: theme.colors.foreground,
|
||||
userSelect: "text",
|
||||
@@ -1402,6 +1707,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
contextLineText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
emptySplitCellText: {
|
||||
color: "transparent",
|
||||
},
|
||||
statusMessageContainer: {
|
||||
borderTopWidth: theme.borderWidth[1],
|
||||
borderTopColor: theme.colors.border,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Image, type ImageSourcePropType } from "react-native";
|
||||
import type { EditorTargetId } from "@server/shared/messages";
|
||||
|
||||
interface EditorAppIconProps {
|
||||
editorId: EditorTargetId;
|
||||
size?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const EDITOR_APP_IMAGES: Record<EditorTargetId, ImageSourcePropType> = {
|
||||
cursor: require("../../../assets/images/editor-apps/cursor.png"),
|
||||
vscode: require("../../../assets/images/editor-apps/vscode.png"),
|
||||
zed: require("../../../assets/images/editor-apps/zed.png"),
|
||||
finder: require("../../../assets/images/editor-apps/finder.png"),
|
||||
explorer: require("../../../assets/images/editor-apps/file-explorer.png"),
|
||||
"file-manager": require("../../../assets/images/editor-apps/file-explorer.png"),
|
||||
};
|
||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||
|
||||
export function EditorAppIcon({
|
||||
editorId,
|
||||
size = 16,
|
||||
}: EditorAppIconProps) {
|
||||
return (
|
||||
<Image
|
||||
source={EDITOR_APP_IMAGES[editorId]}
|
||||
style={{ width: size, height: size }}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -80,6 +80,8 @@ export interface MessageInputProps {
|
||||
isInputActive?: boolean;
|
||||
/** Content to render on the left side of the button row (e.g., AgentStatusBar) */
|
||||
leftContent?: React.ReactNode;
|
||||
/** Content to render on the right side before the voice button (e.g., context window meter) */
|
||||
beforeVoiceContent?: React.ReactNode;
|
||||
/** Content to render on the right side after voice button (e.g., realtime button, cancel button) */
|
||||
rightContent?: React.ReactNode;
|
||||
voiceServerId?: string;
|
||||
@@ -207,6 +209,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
disabled = false,
|
||||
isInputActive = true,
|
||||
leftContent,
|
||||
beforeVoiceContent,
|
||||
rightContent,
|
||||
voiceServerId,
|
||||
voiceAgentId,
|
||||
@@ -1024,6 +1027,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
|
||||
{/* Right: voice button, contextual button (realtime/send/cancel) */}
|
||||
<View style={styles.rightButtonGroup}>
|
||||
{beforeVoiceContent}
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
onPress={handleVoicePress}
|
||||
|
||||
@@ -78,9 +78,17 @@ function createPlanMarkdownRules() {
|
||||
const contentStyle = isOrdered ? styles.ordered_list_content : styles.bullet_list_content;
|
||||
|
||||
return (
|
||||
<View key={node.key} style={[styles.list_item, { flexShrink: 0 }]}>
|
||||
<View key={node.key} style={styles.list_item}>
|
||||
<Text style={iconStyle}>{marker}</Text>
|
||||
<Text style={[contentStyle, { flex: 1, flexShrink: 1, minWidth: 0 }]}>{children}</Text>
|
||||
<View style={[contentStyle, { flex: 1, flexShrink: 1, minWidth: 0 }]}>{children}</View>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
paragraph: (node: any, children: ReactNode[], parent: any, styles: any) => {
|
||||
const isLastChild = parent[0]?.children?.at(-1)?.key === node.key;
|
||||
return (
|
||||
<View key={node.key} style={[styles.paragraph, isLastChild && { marginBottom: 0 }]}>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -203,12 +203,12 @@ export function ComboboxItem({
|
||||
]}
|
||||
>
|
||||
{leadingContent}
|
||||
<View style={styles.comboboxItemContent}>
|
||||
<View style={[styles.comboboxItemContent, description && styles.comboboxItemContentInline]}>
|
||||
<Text numberOfLines={1} style={styles.comboboxItemLabel}>
|
||||
{label}
|
||||
</Text>
|
||||
{description ? (
|
||||
<Text numberOfLines={2} style={styles.comboboxItemDescription}>
|
||||
<Text numberOfLines={1} style={styles.comboboxItemDescription}>
|
||||
{description}
|
||||
</Text>
|
||||
) : null}
|
||||
@@ -838,6 +838,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flex: 1,
|
||||
flexShrink: 1,
|
||||
},
|
||||
comboboxItemContentInline: {
|
||||
flexDirection: "row",
|
||||
alignItems: "baseline",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
comboboxItemLeadingSlot: {
|
||||
width: 16,
|
||||
alignItems: "center",
|
||||
@@ -848,9 +853,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
comboboxItemDescription: {
|
||||
marginTop: 2,
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
flexShrink: 1,
|
||||
},
|
||||
emptyText: {
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
|
||||
@@ -44,6 +44,7 @@ type TooltipContextValue = {
|
||||
setOpen: (open: boolean) => void;
|
||||
triggerRef: React.RefObject<View | null>;
|
||||
enabled: boolean;
|
||||
openOnPress: boolean;
|
||||
delayDuration: number;
|
||||
};
|
||||
|
||||
@@ -107,6 +108,18 @@ function measureElement(element: View): Promise<Rect> {
|
||||
});
|
||||
}
|
||||
|
||||
function isMobileTooltipEnvironment(): boolean {
|
||||
if (Platform.OS !== "web") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof navigator === "undefined") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return /Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent ?? "");
|
||||
}
|
||||
|
||||
function computePosition({
|
||||
triggerRect,
|
||||
contentSize,
|
||||
@@ -214,12 +227,8 @@ export function Tooltip({
|
||||
onOpenChange,
|
||||
});
|
||||
|
||||
const isWeb = Platform.OS === "web";
|
||||
const isMobileWeb =
|
||||
isWeb &&
|
||||
typeof navigator !== "undefined" &&
|
||||
/Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent ?? "");
|
||||
const enabled = isWeb ? (isMobileWeb ? enabledOnMobile : enabledOnDesktop) : enabledOnMobile;
|
||||
const isMobile = isMobileTooltipEnvironment();
|
||||
const enabled = isMobile ? enabledOnMobile : enabledOnDesktop;
|
||||
|
||||
const value = useMemo<TooltipContextValue>(
|
||||
() => ({
|
||||
@@ -227,9 +236,10 @@ export function Tooltip({
|
||||
setOpen: setIsOpen,
|
||||
triggerRef,
|
||||
enabled,
|
||||
openOnPress: isMobile,
|
||||
delayDuration,
|
||||
}),
|
||||
[isOpen, setIsOpen, enabled, delayDuration],
|
||||
[isOpen, setIsOpen, enabled, isMobile, delayDuration],
|
||||
);
|
||||
|
||||
return <TooltipContext.Provider value={value}>{children}</TooltipContext.Provider>;
|
||||
@@ -322,9 +332,17 @@ export function TooltipTrigger({
|
||||
const handlePress = useCallback(
|
||||
(e: any) => {
|
||||
onPress?.(e);
|
||||
if (!ctx.enabled || disabled) {
|
||||
return;
|
||||
}
|
||||
if (ctx.openOnPress) {
|
||||
clearOpenTimer();
|
||||
ctx.setOpen(true);
|
||||
return;
|
||||
}
|
||||
close();
|
||||
},
|
||||
[close, onPress],
|
||||
[clearOpenTimer, close, ctx, disabled, onPress],
|
||||
);
|
||||
|
||||
const triggerProps = {
|
||||
@@ -491,7 +509,7 @@ export function TooltipContent({
|
||||
statusBarTranslucent={Platform.OS === "android"}
|
||||
onRequestClose={() => ctx.setOpen(false)}
|
||||
>
|
||||
<View pointerEvents="box-none" style={styles.overlay}>
|
||||
<Pressable style={styles.overlay} onPress={() => ctx.setOpen(false)}>
|
||||
<Animated.View
|
||||
pointerEvents="none"
|
||||
entering={FadeIn.duration(80)}
|
||||
@@ -512,7 +530,7 @@ export function TooltipContent({
|
||||
>
|
||||
{children}
|
||||
</Animated.View>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useClientActivity } from "@/hooks/use-client-activity";
|
||||
import { usePushTokenRegistration } from "@/hooks/use-push-token-registration";
|
||||
import { clearArchiveAgentPending } from "@/hooks/use-archive-agent";
|
||||
import { prefetchProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { generateMessageId, type StreamItem } from "@/types/stream";
|
||||
import {
|
||||
processTimelineResponse,
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
type Agent,
|
||||
type SessionState,
|
||||
type WorkspaceDescriptor,
|
||||
mergeWorkspaceSnapshotWithExisting,
|
||||
normalizeWorkspaceDescriptor,
|
||||
} from "@/stores/session-store";
|
||||
import { useDraftStore } from "@/stores/draft-store";
|
||||
@@ -69,6 +71,22 @@ export type {
|
||||
const HISTORY_STALE_AFTER_MS = 60_000;
|
||||
const AUTHORITATIVE_REVALIDATION_DEBOUNCE_MS = 300;
|
||||
|
||||
function hasAgentUsageChanged(
|
||||
incomingUsage: Agent["lastUsage"] | undefined,
|
||||
currentUsage: Agent["lastUsage"] | undefined,
|
||||
): boolean {
|
||||
const keys: Array<keyof NonNullable<Agent["lastUsage"]>> = [
|
||||
"inputTokens",
|
||||
"outputTokens",
|
||||
"cachedInputTokens",
|
||||
"totalCostUsd",
|
||||
"contextWindowMaxTokens",
|
||||
"contextWindowUsedTokens",
|
||||
];
|
||||
|
||||
return keys.some((key) => incomingUsage?.[key] !== currentUsage?.[key]);
|
||||
}
|
||||
|
||||
type AudioOutputPayload = Extract<SessionOutboundMessage, { type: "audio_output" }>["payload"];
|
||||
|
||||
interface BufferedAudioChunk {
|
||||
@@ -318,6 +336,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
}
|
||||
|
||||
const workspaces = new Map<string, WorkspaceDescriptor>();
|
||||
const existingWorkspaces = useSessionStore.getState().sessions[serverId]?.workspaces;
|
||||
let cursor: string | null = null;
|
||||
let includeSubscribe = options?.subscribe ?? false;
|
||||
|
||||
@@ -333,7 +352,13 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
|
||||
for (const entry of payload.entries) {
|
||||
const workspace = normalizeWorkspaceDescriptor(entry);
|
||||
workspaces.set(workspace.id, workspace);
|
||||
workspaces.set(
|
||||
workspace.id,
|
||||
mergeWorkspaceSnapshotWithExisting({
|
||||
incoming: workspace,
|
||||
existing: existingWorkspaces?.get(workspace.id),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) {
|
||||
@@ -358,6 +383,15 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
setAgents(serverId, (prev) => {
|
||||
const current = prev.get(agent.id);
|
||||
if (current && agent.updatedAt.getTime() < current.updatedAt.getTime()) {
|
||||
const hasUsageUpdate = hasAgentUsageChanged(agent.lastUsage, current.lastUsage);
|
||||
if (hasUsageUpdate) {
|
||||
const next = new Map(prev);
|
||||
next.set(agent.id, {
|
||||
...current,
|
||||
lastUsage: agent.lastUsage,
|
||||
});
|
||||
return next;
|
||||
}
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(prev);
|
||||
@@ -611,6 +645,34 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
updateSessionClient(serverId, client);
|
||||
}, [serverId, client, updateSessionClient]);
|
||||
|
||||
useEffect(() => {
|
||||
const serverInfo = client.getLastServerInfoMessage();
|
||||
if (!serverInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateSessionServerInfo(serverId, {
|
||||
serverId: serverInfo.serverId,
|
||||
hostname: serverInfo.hostname,
|
||||
version: serverInfo.version,
|
||||
...(serverInfo.capabilities ? { capabilities: serverInfo.capabilities } : {}),
|
||||
...(serverInfo.features ? { features: serverInfo.features } : {}),
|
||||
});
|
||||
}, [client, serverId, updateSessionServerInfo]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
const serverInfo = client.getLastServerInfoMessage();
|
||||
if (!serverInfo?.features?.providersSnapshot) {
|
||||
return;
|
||||
}
|
||||
|
||||
prefetchProvidersSnapshot(serverId, client);
|
||||
}, [client, isConnected, serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!voiceRuntime) {
|
||||
return;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useQuery, useQueries, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
AGENT_PROVIDER_DEFINITIONS,
|
||||
type AgentProviderDefinition,
|
||||
@@ -11,8 +10,7 @@ import type {
|
||||
ProviderSnapshotEntry,
|
||||
} from "@server/server/agent/agent-sdk-types";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useSessionForServer } from "./use-session-directory";
|
||||
import { useProvidersSnapshot } from "./use-providers-snapshot";
|
||||
import {
|
||||
useFormPreferences,
|
||||
mergeProviderPreferences,
|
||||
@@ -320,10 +318,6 @@ function combineInitialValues(
|
||||
return initialValues;
|
||||
}
|
||||
|
||||
function providersSnapshotQueryKey(serverId: string | null, cwd: string | undefined) {
|
||||
return ["providersSnapshot", serverId, cwd ?? ""] as const;
|
||||
}
|
||||
|
||||
export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAgentFormStateResult {
|
||||
const {
|
||||
initialServerId = null,
|
||||
@@ -341,7 +335,6 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
} = useFormPreferences();
|
||||
|
||||
const daemons = useHosts();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Build a set of valid server IDs for preference validation
|
||||
const validServerIds = useMemo(() => new Set(daemons.map((d) => d.serverId)), [daemons]);
|
||||
@@ -374,95 +367,19 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
}
|
||||
}, [isVisible]);
|
||||
|
||||
// Session state for provider model listing
|
||||
const client = useHostRuntimeClient(formState.serverId ?? "");
|
||||
const isConnected = useHostRuntimeIsConnected(formState.serverId ?? "");
|
||||
const supportsProvidersSnapshot = useSessionForServer(
|
||||
formState.serverId,
|
||||
(session) => session?.serverInfo?.features?.providersSnapshot === true,
|
||||
);
|
||||
const {
|
||||
entries: snapshotEntries,
|
||||
isLoading: snapshotIsLoading,
|
||||
isFetching: snapshotIsFetching,
|
||||
error: snapshotError,
|
||||
refresh: refreshSnapshot,
|
||||
} = useProvidersSnapshot(formState.serverId);
|
||||
|
||||
const [debouncedCwd, setDebouncedCwd] = useState<string | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
const trimmed = formState.workingDir.trim();
|
||||
const next = trimmed.length > 0 ? trimmed : undefined;
|
||||
const timer = setTimeout(() => setDebouncedCwd(next), 180);
|
||||
return () => clearTimeout(timer);
|
||||
}, [formState.workingDir]);
|
||||
|
||||
const snapshotQueryKey = useMemo(
|
||||
() => providersSnapshotQueryKey(formState.serverId, debouncedCwd),
|
||||
[debouncedCwd, formState.serverId],
|
||||
);
|
||||
|
||||
const providersSnapshotQuery = useQuery({
|
||||
queryKey: snapshotQueryKey,
|
||||
enabled: Boolean(
|
||||
supportsProvidersSnapshot &&
|
||||
isVisible &&
|
||||
isTargetDaemonReady &&
|
||||
formState.serverId &&
|
||||
client &&
|
||||
isConnected,
|
||||
),
|
||||
staleTime: 60 * 1000,
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
return client.getProvidersSnapshot({ cwd: debouncedCwd });
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!supportsProvidersSnapshot ||
|
||||
!client ||
|
||||
!isConnected ||
|
||||
!isVisible ||
|
||||
!isTargetDaemonReady ||
|
||||
!formState.serverId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
return client.on("providers_snapshot_update", (message) => {
|
||||
if (message.type !== "providers_snapshot_update") {
|
||||
return;
|
||||
}
|
||||
if (message.payload.cwd !== undefined && message.payload.cwd !== debouncedCwd) {
|
||||
return;
|
||||
}
|
||||
queryClient.setQueryData(snapshotQueryKey, {
|
||||
entries: message.payload.entries,
|
||||
generatedAt: message.payload.generatedAt,
|
||||
requestId: "providers_snapshot_update",
|
||||
});
|
||||
});
|
||||
}, [
|
||||
client,
|
||||
debouncedCwd,
|
||||
formState.serverId,
|
||||
isConnected,
|
||||
isTargetDaemonReady,
|
||||
isVisible,
|
||||
queryClient,
|
||||
snapshotQueryKey,
|
||||
supportsProvidersSnapshot,
|
||||
]);
|
||||
|
||||
const snapshotEntries = providersSnapshotQuery.data?.entries ?? undefined;
|
||||
const allProviderEntries = useMemo(
|
||||
() => (supportsProvidersSnapshot ? snapshotEntries ?? [] : undefined),
|
||||
[snapshotEntries, supportsProvidersSnapshot],
|
||||
);
|
||||
const allProviderEntries = useMemo(() => snapshotEntries ?? [], [snapshotEntries]);
|
||||
const snapshotProviderDefinitions = useMemo(() => {
|
||||
if (!supportsProvidersSnapshot) {
|
||||
return [];
|
||||
}
|
||||
const snapshotProviders = new Set((snapshotEntries ?? []).map((entry) => entry.provider));
|
||||
return allProviderDefinitions.filter((definition) => snapshotProviders.has(definition.id));
|
||||
}, [snapshotEntries, supportsProvidersSnapshot]);
|
||||
}, [snapshotEntries]);
|
||||
const snapshotProviderDefinitionMap = useMemo(
|
||||
() =>
|
||||
new Map<AgentProvider, AgentProviderDefinition>(
|
||||
@@ -498,157 +415,13 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
snapshotSelectedEntry?.modes ??
|
||||
snapshotProviderDefinitionMap.get(formState.provider)?.modes ??
|
||||
[];
|
||||
|
||||
// COMPAT(providersSnapshot): legacy fallback for daemons without snapshot support — remove when all daemons support snapshots
|
||||
const availableProvidersQuery = useQuery({
|
||||
queryKey: ["availableProviders", formState.serverId],
|
||||
enabled: Boolean(
|
||||
!supportsProvidersSnapshot &&
|
||||
isVisible &&
|
||||
isTargetDaemonReady &&
|
||||
formState.serverId &&
|
||||
client &&
|
||||
isConnected,
|
||||
),
|
||||
staleTime: 60 * 1000,
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
const payload = await client.listAvailableProviders();
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
return payload.providers.filter((entry) => entry.available).map((entry) => entry.provider);
|
||||
},
|
||||
});
|
||||
const legacyProviderDefinitions = useMemo(() => {
|
||||
const availableProviders = availableProvidersQuery.data;
|
||||
if (!availableProviders) {
|
||||
return [];
|
||||
}
|
||||
const available = new Set(availableProviders);
|
||||
return allProviderDefinitions.filter((definition) => available.has(definition.id));
|
||||
}, [availableProvidersQuery.data]);
|
||||
const legacyProviderDefinitionMap = useMemo(
|
||||
() =>
|
||||
new Map<AgentProvider, AgentProviderDefinition>(
|
||||
legacyProviderDefinitions.map((definition) => [definition.id, definition]),
|
||||
),
|
||||
[legacyProviderDefinitions],
|
||||
);
|
||||
|
||||
// COMPAT(providersSnapshot): legacy fallback for daemons without snapshot support — remove when all daemons support snapshots
|
||||
const legacySelectedProviderModelsQuery = useQuery({
|
||||
queryKey: ["providerModels", formState.serverId, formState.provider],
|
||||
enabled: Boolean(
|
||||
!supportsProvidersSnapshot &&
|
||||
isVisible &&
|
||||
isTargetDaemonReady &&
|
||||
formState.serverId &&
|
||||
client &&
|
||||
isConnected &&
|
||||
legacyProviderDefinitionMap.has(formState.provider),
|
||||
),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
const payload = await client.listProviderModels(formState.provider, {
|
||||
cwd: debouncedCwd,
|
||||
});
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
return payload.models ?? [];
|
||||
},
|
||||
});
|
||||
const legacySelectedProviderModels = legacySelectedProviderModelsQuery.data ?? null;
|
||||
|
||||
// COMPAT(providersSnapshot): legacy fallback for daemons without snapshot support — remove when all daemons support snapshots
|
||||
const providerModesQuery = useQuery({
|
||||
queryKey: ["providerModes", formState.serverId, formState.provider, debouncedCwd],
|
||||
enabled: Boolean(
|
||||
!supportsProvidersSnapshot &&
|
||||
isVisible &&
|
||||
isTargetDaemonReady &&
|
||||
formState.serverId &&
|
||||
client &&
|
||||
isConnected &&
|
||||
legacyProviderDefinitionMap.has(formState.provider),
|
||||
),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
const payload = await client.listProviderModes(formState.provider, {
|
||||
cwd: debouncedCwd,
|
||||
});
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
return payload.modes ?? [];
|
||||
},
|
||||
});
|
||||
|
||||
// COMPAT(providersSnapshot): legacy fallback for daemons without snapshot support — remove when all daemons support snapshots
|
||||
const allProviderModelQueries = useQueries({
|
||||
queries: (supportsProvidersSnapshot ? [] : legacyProviderDefinitions).map((def) => ({
|
||||
queryKey: ["providerModels", formState.serverId, def.id],
|
||||
enabled: Boolean(
|
||||
isVisible && isTargetDaemonReady && formState.serverId && client && isConnected,
|
||||
),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
const payload = await client.listProviderModels(def.id as AgentProvider, {
|
||||
cwd: debouncedCwd,
|
||||
});
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
return payload.models ?? [];
|
||||
},
|
||||
})),
|
||||
});
|
||||
const legacyAllProviderModels = useMemo(() => {
|
||||
const map = new Map<string, AgentModelDefinition[]>();
|
||||
for (let i = 0; i < legacyProviderDefinitions.length; i++) {
|
||||
const query = allProviderModelQueries[i];
|
||||
if (query?.data) {
|
||||
map.set(legacyProviderDefinitions[i]!.id, query.data);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [allProviderModelQueries, legacyProviderDefinitions]);
|
||||
const legacySelectedProviderModes =
|
||||
providerModesQuery.data ?? legacyProviderDefinitionMap.get(formState.provider)?.modes ?? [];
|
||||
|
||||
const providerDefinitions = supportsProvidersSnapshot
|
||||
? snapshotProviderDefinitions
|
||||
: legacyProviderDefinitions;
|
||||
const providerDefinitionMap = supportsProvidersSnapshot
|
||||
? snapshotProviderDefinitionMap
|
||||
: legacyProviderDefinitionMap;
|
||||
const selectableProviderDefinitionMap = supportsProvidersSnapshot
|
||||
? snapshotSelectableProviderDefinitionMap
|
||||
: legacyProviderDefinitionMap;
|
||||
const allProviderModels = supportsProvidersSnapshot
|
||||
? snapshotAllProviderModels
|
||||
: legacyAllProviderModels;
|
||||
const availableModels = supportsProvidersSnapshot
|
||||
? snapshotSelectedProviderModels
|
||||
: legacySelectedProviderModels;
|
||||
const modeOptions = supportsProvidersSnapshot
|
||||
? snapshotSelectedProviderModes
|
||||
: legacySelectedProviderModes;
|
||||
const isAllModelsLoading = supportsProvidersSnapshot
|
||||
? providersSnapshotQuery.isLoading || providersSnapshotQuery.isFetching
|
||||
: allProviderModelQueries.some((q) => q.isLoading);
|
||||
const providerDefinitions = snapshotProviderDefinitions;
|
||||
const providerDefinitionMap = snapshotProviderDefinitionMap;
|
||||
const selectableProviderDefinitionMap = snapshotSelectableProviderDefinitionMap;
|
||||
const allProviderModels = snapshotAllProviderModels;
|
||||
const availableModels = snapshotSelectedProviderModels;
|
||||
const modeOptions = snapshotSelectedProviderModes;
|
||||
const isAllModelsLoading = snapshotIsLoading || snapshotIsFetching;
|
||||
|
||||
// Combine initialValues with initialServerId for resolution
|
||||
const combinedInitialValues = useMemo((): FormInitialValues | undefined => {
|
||||
@@ -870,15 +643,8 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
}, []);
|
||||
|
||||
const refreshProviderModels = useCallback(() => {
|
||||
if (supportsProvidersSnapshot) {
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
void client.refreshProvidersSnapshot({ cwd: debouncedCwd });
|
||||
return;
|
||||
}
|
||||
void legacySelectedProviderModelsQuery.refetch();
|
||||
}, [client, debouncedCwd, legacySelectedProviderModelsQuery, supportsProvidersSnapshot]);
|
||||
refreshSnapshot();
|
||||
}, [refreshSnapshot]);
|
||||
|
||||
const persistFormPreferences = useCallback(async () => {
|
||||
const resolvedModel = resolveEffectiveModel(availableModels, formState.model);
|
||||
@@ -914,17 +680,8 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
const effectiveModel = resolveEffectiveModel(availableModels, formState.model);
|
||||
const resolvedModelId = effectiveModel?.id ?? formState.model;
|
||||
const availableThinkingOptions = effectiveModel?.thinkingOptions ?? [];
|
||||
const isModelLoading = supportsProvidersSnapshot
|
||||
? providersSnapshotQuery.isLoading || providersSnapshotQuery.isFetching
|
||||
: legacySelectedProviderModelsQuery.isLoading || legacySelectedProviderModelsQuery.isFetching;
|
||||
const modelError =
|
||||
supportsProvidersSnapshot
|
||||
? providersSnapshotQuery.error instanceof Error
|
||||
? providersSnapshotQuery.error.message
|
||||
: null
|
||||
: legacySelectedProviderModelsQuery.error instanceof Error
|
||||
? legacySelectedProviderModelsQuery.error.message
|
||||
: null;
|
||||
const isModelLoading = snapshotIsLoading || snapshotIsFetching;
|
||||
const modelError = snapshotError;
|
||||
|
||||
const workingDirIsEmpty = !formState.workingDir.trim();
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const asyncStorageMock = vi.hoisted(() => ({
|
||||
getItem: vi.fn<(_: string) => Promise<string | null>>(),
|
||||
setItem: vi.fn<(_: string, __: string) => Promise<void>>(),
|
||||
}));
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => ({
|
||||
default: asyncStorageMock,
|
||||
}));
|
||||
|
||||
describe("use-changes-preferences", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
asyncStorageMock.getItem.mockReset();
|
||||
asyncStorageMock.setItem.mockReset();
|
||||
});
|
||||
|
||||
it("defaults to unified layout with visible whitespace", async () => {
|
||||
asyncStorageMock.getItem.mockResolvedValue(null);
|
||||
asyncStorageMock.setItem.mockResolvedValue();
|
||||
|
||||
const mod = await import("./use-changes-preferences");
|
||||
const result = await mod.loadChangesPreferencesFromStorage();
|
||||
|
||||
expect(result).toEqual(mod.DEFAULT_CHANGES_PREFERENCES);
|
||||
expect(asyncStorageMock.setItem).toHaveBeenCalledWith(
|
||||
"@paseo:changes-preferences",
|
||||
JSON.stringify(mod.DEFAULT_CHANGES_PREFERENCES),
|
||||
);
|
||||
});
|
||||
|
||||
it("migrates the legacy wrap-lines toggle into the new preferences object", async () => {
|
||||
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
|
||||
if (key === "diff-wrap-lines") {
|
||||
return "true";
|
||||
}
|
||||
return null;
|
||||
});
|
||||
asyncStorageMock.setItem.mockResolvedValue();
|
||||
|
||||
const mod = await import("./use-changes-preferences");
|
||||
const result = await mod.loadChangesPreferencesFromStorage();
|
||||
|
||||
expect(result).toEqual({
|
||||
layout: "unified",
|
||||
wrapLines: true,
|
||||
hideWhitespace: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("loads persisted layout and whitespace preferences", async () => {
|
||||
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
|
||||
if (key === "@paseo:changes-preferences") {
|
||||
return JSON.stringify({
|
||||
layout: "split",
|
||||
hideWhitespace: true,
|
||||
wrapLines: false,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const mod = await import("./use-changes-preferences");
|
||||
const result = await mod.loadChangesPreferencesFromStorage();
|
||||
|
||||
expect(result).toEqual({
|
||||
layout: "split",
|
||||
hideWhitespace: true,
|
||||
wrapLines: false,
|
||||
});
|
||||
expect(asyncStorageMock.setItem).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useCallback } from "react";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { z } from "zod";
|
||||
|
||||
const CHANGES_PREFERENCES_STORAGE_KEY = "@paseo:changes-preferences";
|
||||
const LEGACY_WRAP_LINES_STORAGE_KEY = "diff-wrap-lines";
|
||||
const CHANGES_PREFERENCES_QUERY_KEY = ["changes-preferences"];
|
||||
|
||||
const changesPreferencesSchema = z.object({
|
||||
layout: z.enum(["unified", "split"]).optional(),
|
||||
wrapLines: z.boolean().optional(),
|
||||
hideWhitespace: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export interface ChangesPreferences {
|
||||
layout: "unified" | "split";
|
||||
wrapLines: boolean;
|
||||
hideWhitespace: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_CHANGES_PREFERENCES: ChangesPreferences = {
|
||||
layout: "unified",
|
||||
wrapLines: false,
|
||||
hideWhitespace: false,
|
||||
};
|
||||
|
||||
async function loadLegacyWrapLinesPreference(): Promise<boolean | null> {
|
||||
const legacyValue = await AsyncStorage.getItem(LEGACY_WRAP_LINES_STORAGE_KEY);
|
||||
if (legacyValue === "true") {
|
||||
return true;
|
||||
}
|
||||
if (legacyValue === "false") {
|
||||
return false;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function loadChangesPreferencesFromStorage(): Promise<ChangesPreferences> {
|
||||
const stored = await AsyncStorage.getItem(CHANGES_PREFERENCES_STORAGE_KEY);
|
||||
if (stored) {
|
||||
const parsed = changesPreferencesSchema.safeParse(JSON.parse(stored));
|
||||
if (parsed.success) {
|
||||
return { ...DEFAULT_CHANGES_PREFERENCES, ...parsed.data };
|
||||
}
|
||||
}
|
||||
|
||||
const legacyWrapLines = await loadLegacyWrapLinesPreference();
|
||||
const next = {
|
||||
...DEFAULT_CHANGES_PREFERENCES,
|
||||
...(legacyWrapLines !== null ? { wrapLines: legacyWrapLines } : {}),
|
||||
} satisfies ChangesPreferences;
|
||||
await AsyncStorage.setItem(CHANGES_PREFERENCES_STORAGE_KEY, JSON.stringify(next));
|
||||
return next;
|
||||
}
|
||||
|
||||
export interface UseChangesPreferencesReturn {
|
||||
preferences: ChangesPreferences;
|
||||
isLoading: boolean;
|
||||
updatePreferences: (updates: Partial<ChangesPreferences>) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useChangesPreferences(): UseChangesPreferencesReturn {
|
||||
const queryClient = useQueryClient();
|
||||
const { data, isPending } = useQuery({
|
||||
queryKey: CHANGES_PREFERENCES_QUERY_KEY,
|
||||
queryFn: loadChangesPreferencesFromStorage,
|
||||
staleTime: Infinity,
|
||||
gcTime: Infinity,
|
||||
});
|
||||
|
||||
const updatePreferences = useCallback(
|
||||
async (updates: Partial<ChangesPreferences>) => {
|
||||
const prev =
|
||||
queryClient.getQueryData<ChangesPreferences>(CHANGES_PREFERENCES_QUERY_KEY) ??
|
||||
DEFAULT_CHANGES_PREFERENCES;
|
||||
const next = { ...prev, ...updates };
|
||||
queryClient.setQueryData<ChangesPreferences>(CHANGES_PREFERENCES_QUERY_KEY, next);
|
||||
await AsyncStorage.setItem(CHANGES_PREFERENCES_STORAGE_KEY, JSON.stringify(next));
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
return {
|
||||
preferences: data ?? DEFAULT_CHANGES_PREFERENCES,
|
||||
isLoading: isPending,
|
||||
updatePreferences,
|
||||
};
|
||||
}
|
||||
@@ -13,8 +13,9 @@ function checkoutDiffQueryKey(
|
||||
cwd: string,
|
||||
mode: "uncommitted" | "base",
|
||||
baseRef?: string,
|
||||
ignoreWhitespace?: boolean,
|
||||
) {
|
||||
return ["checkoutDiff", serverId, cwd, mode, baseRef ?? ""] as const;
|
||||
return ["checkoutDiff", serverId, cwd, mode, baseRef ?? "", ignoreWhitespace === true] as const;
|
||||
}
|
||||
|
||||
interface UseCheckoutDiffQueryOptions {
|
||||
@@ -22,6 +23,7 @@ interface UseCheckoutDiffQueryOptions {
|
||||
cwd: string;
|
||||
mode: "uncommitted" | "base";
|
||||
baseRef?: string;
|
||||
ignoreWhitespace?: boolean;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -35,12 +37,16 @@ export type HighlightToken = NonNullable<DiffLine["tokens"]>[number];
|
||||
function normalizeCheckoutDiffCompare(compare: {
|
||||
mode: "uncommitted" | "base";
|
||||
baseRef?: string;
|
||||
}): { mode: "uncommitted" | "base"; baseRef?: string } {
|
||||
ignoreWhitespace?: boolean;
|
||||
}): { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean } {
|
||||
const ignoreWhitespace = compare.ignoreWhitespace === true;
|
||||
if (compare.mode === "uncommitted") {
|
||||
return { mode: "uncommitted" };
|
||||
return { mode: "uncommitted", ignoreWhitespace };
|
||||
}
|
||||
const trimmedBaseRef = compare.baseRef?.trim();
|
||||
return trimmedBaseRef ? { mode: "base", baseRef: trimmedBaseRef } : { mode: "base" };
|
||||
return trimmedBaseRef
|
||||
? { mode: "base", baseRef: trimmedBaseRef, ignoreWhitespace }
|
||||
: { mode: "base", ignoreWhitespace };
|
||||
}
|
||||
|
||||
export function useCheckoutDiffQuery({
|
||||
@@ -48,6 +54,7 @@ export function useCheckoutDiffQuery({
|
||||
cwd,
|
||||
mode,
|
||||
baseRef,
|
||||
ignoreWhitespace,
|
||||
enabled = true,
|
||||
}: UseCheckoutDiffQueryOptions) {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -60,14 +67,15 @@ export function useCheckoutDiffQuery({
|
||||
const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
||||
const hookInstanceId = useId();
|
||||
const normalizedCompare = useMemo(
|
||||
() => normalizeCheckoutDiffCompare({ mode, baseRef }),
|
||||
[mode, baseRef],
|
||||
() => normalizeCheckoutDiffCompare({ mode, baseRef, ignoreWhitespace }),
|
||||
[mode, baseRef, ignoreWhitespace],
|
||||
);
|
||||
const compareMode = normalizedCompare.mode;
|
||||
const compareBaseRef = normalizedCompare.baseRef;
|
||||
const compareIgnoreWhitespace = normalizedCompare.ignoreWhitespace;
|
||||
const queryKey = useMemo(
|
||||
() => checkoutDiffQueryKey(serverId, cwd, mode, baseRef),
|
||||
[serverId, cwd, mode, baseRef],
|
||||
() => checkoutDiffQueryKey(serverId, cwd, mode, baseRef, compareIgnoreWhitespace),
|
||||
[serverId, cwd, mode, baseRef, compareIgnoreWhitespace],
|
||||
);
|
||||
|
||||
const query = useQuery({
|
||||
@@ -79,6 +87,7 @@ export function useCheckoutDiffQuery({
|
||||
const payload = await client.getCheckoutDiff(cwd, {
|
||||
mode: compareMode,
|
||||
baseRef: compareBaseRef,
|
||||
ignoreWhitespace: compareIgnoreWhitespace,
|
||||
});
|
||||
return {
|
||||
...payload,
|
||||
@@ -104,6 +113,7 @@ export function useCheckoutDiffQuery({
|
||||
cwd,
|
||||
compareMode,
|
||||
compareBaseRef ?? "",
|
||||
compareIgnoreWhitespace ? "ignore-ws" : "keep-ws",
|
||||
].join(":");
|
||||
let cancelled = false;
|
||||
|
||||
@@ -145,6 +155,7 @@ export function useCheckoutDiffQuery({
|
||||
{
|
||||
mode: compareMode,
|
||||
baseRef: compareBaseRef,
|
||||
ignoreWhitespace: compareIgnoreWhitespace,
|
||||
},
|
||||
{ subscriptionId },
|
||||
)
|
||||
@@ -191,6 +202,7 @@ export function useCheckoutDiffQuery({
|
||||
serverId,
|
||||
compareMode,
|
||||
compareBaseRef,
|
||||
compareIgnoreWhitespace,
|
||||
queryKey,
|
||||
queryClient,
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolvePreferredEditorId } from "./use-preferred-editor";
|
||||
|
||||
describe("resolvePreferredEditorId", () => {
|
||||
it("keeps the stored editor when it is still available", () => {
|
||||
expect(resolvePreferredEditorId(["cursor", "vscode"], "vscode")).toBe("vscode");
|
||||
});
|
||||
|
||||
it("falls back to the first available editor when the stored one is missing", () => {
|
||||
expect(resolvePreferredEditorId(["zed", "finder"], "cursor")).toBe("zed");
|
||||
});
|
||||
|
||||
it("falls back when a platform-specific file manager target is unavailable", () => {
|
||||
expect(resolvePreferredEditorId(["explorer", "vscode"], "finder")).toBe("explorer");
|
||||
});
|
||||
|
||||
it("returns null when no editors are available", () => {
|
||||
expect(resolvePreferredEditorId([], "cursor")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useCallback } from "react";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { EditorTargetIdSchema, type EditorTargetId } from "@server/shared/messages";
|
||||
|
||||
const PREFERRED_EDITOR_STORAGE_KEY = "@paseo:preferred-editor";
|
||||
const PREFERRED_EDITOR_QUERY_KEY = ["preferred-editor"];
|
||||
|
||||
async function loadPreferredEditor(): Promise<EditorTargetId | null> {
|
||||
const stored = await AsyncStorage.getItem(PREFERRED_EDITOR_STORAGE_KEY);
|
||||
if (!stored) {
|
||||
return null;
|
||||
}
|
||||
const parsed = EditorTargetIdSchema.safeParse(stored);
|
||||
return parsed.success ? parsed.data : null;
|
||||
}
|
||||
|
||||
export function resolvePreferredEditorId(
|
||||
availableEditorIds: readonly EditorTargetId[],
|
||||
storedEditorId: EditorTargetId | null | undefined,
|
||||
): EditorTargetId | null {
|
||||
if (
|
||||
storedEditorId &&
|
||||
availableEditorIds.some((availableEditorId) => availableEditorId === storedEditorId)
|
||||
) {
|
||||
return storedEditorId;
|
||||
}
|
||||
return availableEditorIds[0] ?? null;
|
||||
}
|
||||
|
||||
export function usePreferredEditor() {
|
||||
const queryClient = useQueryClient();
|
||||
const { data, isPending } = useQuery({
|
||||
queryKey: PREFERRED_EDITOR_QUERY_KEY,
|
||||
queryFn: loadPreferredEditor,
|
||||
staleTime: Infinity,
|
||||
gcTime: Infinity,
|
||||
});
|
||||
|
||||
const updatePreferredEditor = useCallback(
|
||||
async (editorId: EditorTargetId | null) => {
|
||||
queryClient.setQueryData(PREFERRED_EDITOR_QUERY_KEY, editorId);
|
||||
if (editorId) {
|
||||
await AsyncStorage.setItem(PREFERRED_EDITOR_STORAGE_KEY, editorId);
|
||||
return;
|
||||
}
|
||||
await AsyncStorage.removeItem(PREFERRED_EDITOR_STORAGE_KEY);
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
return {
|
||||
preferredEditorId: data ?? null,
|
||||
isLoading: isPending,
|
||||
updatePreferredEditor,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useSessionForServer } from "./use-session-directory";
|
||||
import { queryClient as singletonQueryClient } from "@/query/query-client";
|
||||
|
||||
export function providersSnapshotQueryKey(serverId: string | null) {
|
||||
return ["providersSnapshot", serverId] as const;
|
||||
}
|
||||
|
||||
interface UseProvidersSnapshotResult {
|
||||
entries: ProviderSnapshotEntry[] | undefined;
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
error: string | null;
|
||||
supportsSnapshot: boolean;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
export function useProvidersSnapshot(serverId: string | null): UseProvidersSnapshotResult {
|
||||
const queryClient = useQueryClient();
|
||||
const client = useHostRuntimeClient(serverId ?? "");
|
||||
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
|
||||
const supportsSnapshot = useSessionForServer(
|
||||
serverId,
|
||||
(session) => session?.serverInfo?.features?.providersSnapshot === true,
|
||||
);
|
||||
|
||||
const queryKey = useMemo(() => providersSnapshotQueryKey(serverId), [serverId]);
|
||||
|
||||
const snapshotQuery = useQuery({
|
||||
queryKey,
|
||||
enabled: Boolean(supportsSnapshot && serverId && client && isConnected),
|
||||
staleTime: 60_000,
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
return client.getProvidersSnapshot();
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!supportsSnapshot || !client || !isConnected || !serverId) {
|
||||
return;
|
||||
}
|
||||
|
||||
return client.on("providers_snapshot_update", (message) => {
|
||||
if (message.type !== "providers_snapshot_update") {
|
||||
return;
|
||||
}
|
||||
queryClient.setQueryData(queryKey, {
|
||||
entries: message.payload.entries,
|
||||
generatedAt: message.payload.generatedAt,
|
||||
requestId: "providers_snapshot_update",
|
||||
});
|
||||
});
|
||||
}, [client, isConnected, serverId, queryClient, queryKey, supportsSnapshot]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
void client.refreshProvidersSnapshot();
|
||||
}, [client]);
|
||||
|
||||
return {
|
||||
entries: snapshotQuery.data?.entries ?? undefined,
|
||||
isLoading: snapshotQuery.isLoading,
|
||||
isFetching: snapshotQuery.isFetching,
|
||||
error: snapshotQuery.error instanceof Error ? snapshotQuery.error.message : null,
|
||||
supportsSnapshot,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
|
||||
export function prefetchProvidersSnapshot(serverId: string, client: DaemonClient): void {
|
||||
const queryKey = providersSnapshotQueryKey(serverId);
|
||||
void singletonQueryClient.prefetchQuery({
|
||||
queryKey,
|
||||
staleTime: 60_000,
|
||||
queryFn: () => client.getProvidersSnapshot(),
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react";
|
||||
import type { WorkspaceDescriptorPayload } from "@server/shared/messages";
|
||||
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
mergeWorkspaceSnapshotWithExisting,
|
||||
normalizeWorkspaceDescriptor,
|
||||
useSessionStore,
|
||||
} from "@/stores/session-store";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
@@ -372,6 +376,7 @@ export function useSidebarWorkspacesList(options?: {
|
||||
}
|
||||
void (async () => {
|
||||
const next = new Map<string, WorkspaceDescriptor>();
|
||||
const existingWorkspaces = useSessionStore.getState().sessions[serverId]?.workspaces;
|
||||
let cursor: string | null = null;
|
||||
try {
|
||||
while (true) {
|
||||
@@ -381,7 +386,13 @@ export function useSidebarWorkspacesList(options?: {
|
||||
});
|
||||
for (const entry of payload.entries) {
|
||||
const workspace = toWorkspaceDescriptor(entry);
|
||||
next.set(workspace.id, workspace);
|
||||
next.set(
|
||||
workspace.id,
|
||||
mergeWorkspaceSnapshotWithExisting({
|
||||
incoming: workspace,
|
||||
existing: existingWorkspaces?.get(workspace.id),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) {
|
||||
break;
|
||||
|
||||
@@ -62,12 +62,12 @@ import { settingsStyles } from "@/styles/settings";
|
||||
import { THINKING_TONE_NATIVE_PCM_BASE64 } from "@/utils/thinking-tone.native-pcm";
|
||||
import { useVoiceAudioEngineOptional } from "@/contexts/voice-context";
|
||||
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { isCompactFormFactor } from "@/constants/layout";
|
||||
import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import type { ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Section definitions
|
||||
@@ -431,55 +431,49 @@ interface ProvidersSectionProps {
|
||||
|
||||
function ProvidersSection({ routeServerId }: ProvidersSectionProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const client = useHostRuntimeClient(routeServerId);
|
||||
const isConnected = useHostRuntimeIsConnected(routeServerId);
|
||||
const [entries, setEntries] = useState<ProviderSnapshotEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { entries, isLoading, isFetching, refresh } = useProvidersSnapshot(routeServerId);
|
||||
const [diagnosticProvider, setDiagnosticProvider] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || !isConnected) {
|
||||
setEntries([]);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
client
|
||||
.getProvidersSnapshot()
|
||||
.then((result) => {
|
||||
if (!cancelled) setEntries(result.entries);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setEntries([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [client, isConnected]);
|
||||
|
||||
const hasServer = routeServerId.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<View style={settingsStyles.section}>
|
||||
<Text style={settingsStyles.sectionTitle}>Providers</Text>
|
||||
<View style={settingsStyles.sectionHeader}>
|
||||
<Text style={settingsStyles.sectionHeaderTitle}>Providers</Text>
|
||||
{hasServer && isConnected ? (
|
||||
<Pressable
|
||||
onPress={refresh}
|
||||
disabled={isFetching}
|
||||
style={[
|
||||
settingsStyles.sectionHeaderLink,
|
||||
isFetching ? { opacity: 0.5 } : null,
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
color: theme.colors.primary,
|
||||
fontSize: theme.fontSize.xs,
|
||||
}}
|
||||
>
|
||||
Refresh
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
{!hasServer || !isConnected ? (
|
||||
<View style={[settingsStyles.card, styles.emptyCard]}>
|
||||
<Text style={styles.emptyText}>Connect to a host to see providers</Text>
|
||||
</View>
|
||||
) : loading ? (
|
||||
) : isLoading ? (
|
||||
<View style={[settingsStyles.card, styles.emptyCard]}>
|
||||
<Text style={styles.emptyText}>Loading...</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={[settingsStyles.card, styles.audioCard]}>
|
||||
{AGENT_PROVIDER_DEFINITIONS.map((def) => {
|
||||
const entry = entries.find((e) => e.provider === def.id);
|
||||
const entry = entries?.find((e) => e.provider === def.id);
|
||||
const status = entry?.status ?? "unavailable";
|
||||
const ProviderIcon = getProviderIcon(def.id);
|
||||
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { ActivityIndicator, Platform, Pressable, Text, View } from "react-native";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { Check, ChevronDown } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import type {
|
||||
EditorTargetDescriptorPayload,
|
||||
EditorTargetId,
|
||||
} from "@server/shared/messages";
|
||||
import { EditorAppIcon } from "@/components/icons/editor-app-icons";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import {
|
||||
resolvePreferredEditorId,
|
||||
usePreferredEditor,
|
||||
} from "@/hooks/use-preferred-editor";
|
||||
import { isAbsolutePath } from "@/utils/path";
|
||||
|
||||
interface WorkspaceOpenInEditorButtonProps {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
export function WorkspaceOpenInEditorButton({
|
||||
serverId,
|
||||
cwd,
|
||||
}: WorkspaceOpenInEditorButtonProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const toast = useToast();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const { preferredEditorId, updatePreferredEditor } = usePreferredEditor();
|
||||
|
||||
const shouldLoadEditors =
|
||||
Platform.OS === "web" &&
|
||||
Boolean(client && isConnected) &&
|
||||
cwd.trim().length > 0 &&
|
||||
isAbsolutePath(cwd);
|
||||
|
||||
const availableEditorsQuery = useQuery<EditorTargetDescriptorPayload[]>({
|
||||
queryKey: ["available-editors", serverId],
|
||||
enabled: shouldLoadEditors,
|
||||
staleTime: 60_000,
|
||||
retry: false,
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const payload = await client.listAvailableEditors();
|
||||
return payload.error ? [] : payload.editors;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const availableEditors = availableEditorsQuery.data ?? [];
|
||||
const availableEditorIds = useMemo(
|
||||
() => availableEditors.map((editor: EditorTargetDescriptorPayload) => editor.id),
|
||||
[availableEditors],
|
||||
);
|
||||
const effectivePreferredEditorId = useMemo(
|
||||
() => resolvePreferredEditorId(availableEditorIds, preferredEditorId),
|
||||
[availableEditorIds, preferredEditorId],
|
||||
);
|
||||
const primaryOption =
|
||||
availableEditors.find(
|
||||
(editor: EditorTargetDescriptorPayload) => editor.id === effectivePreferredEditorId,
|
||||
) ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!effectivePreferredEditorId || effectivePreferredEditorId === preferredEditorId) {
|
||||
return;
|
||||
}
|
||||
void updatePreferredEditor(effectivePreferredEditorId).catch(() => undefined);
|
||||
}, [effectivePreferredEditorId, preferredEditorId, updatePreferredEditor]);
|
||||
|
||||
const openMutation = useMutation({
|
||||
mutationFn: async (editorId: EditorTargetId) => {
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
const payload = await client.openInEditor(cwd, editorId);
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
return editorId;
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to open in editor");
|
||||
},
|
||||
});
|
||||
|
||||
const handleOpenEditor = useCallback(
|
||||
(editorId: EditorTargetId) => {
|
||||
void updatePreferredEditor(editorId).catch(() => undefined);
|
||||
openMutation.mutate(editorId);
|
||||
},
|
||||
[openMutation, updatePreferredEditor],
|
||||
);
|
||||
|
||||
if (!shouldLoadEditors || !primaryOption || availableEditors.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<View style={styles.splitButton}>
|
||||
<Pressable
|
||||
testID="workspace-open-in-editor-primary"
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.splitButtonPrimary,
|
||||
(hovered || pressed) && styles.splitButtonPrimaryHovered,
|
||||
openMutation.isPending && styles.splitButtonPrimaryDisabled,
|
||||
]}
|
||||
onPress={() => handleOpenEditor(primaryOption.id)}
|
||||
disabled={openMutation.isPending}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Open workspace in ${primaryOption.label}`}
|
||||
>
|
||||
{openMutation.isPending ? (
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
color={theme.colors.foreground}
|
||||
style={styles.splitButtonSpinnerOnly}
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.splitButtonContent}>
|
||||
<EditorAppIcon
|
||||
editorId={primaryOption.id}
|
||||
size={16}
|
||||
color={theme.colors.foregroundMuted}
|
||||
/>
|
||||
<Text style={styles.splitButtonText}>Open</Text>
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
{availableEditors.length > 1 ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
testID="workspace-open-in-editor-caret"
|
||||
style={({ hovered, pressed, open }) => [
|
||||
styles.splitButtonCaret,
|
||||
(hovered || pressed || open) && styles.splitButtonCaretHovered,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Choose editor"
|
||||
>
|
||||
<ChevronDown size={16} color={theme.colors.foregroundMuted} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
minWidth={148}
|
||||
maxWidth={176}
|
||||
testID="workspace-open-in-editor-menu"
|
||||
>
|
||||
{availableEditors.map((editor: EditorTargetDescriptorPayload) => (
|
||||
<DropdownMenuItem
|
||||
key={editor.id}
|
||||
testID={`workspace-open-in-editor-item-${editor.id}`}
|
||||
leading={
|
||||
<EditorAppIcon
|
||||
editorId={editor.id}
|
||||
size={16}
|
||||
color={theme.colors.foregroundMuted}
|
||||
/>
|
||||
}
|
||||
trailing={
|
||||
editor.id === effectivePreferredEditorId
|
||||
? <Check size={16} color={theme.colors.foregroundMuted} />
|
||||
: undefined
|
||||
}
|
||||
onSelect={() => handleOpenEditor(editor.id)}
|
||||
>
|
||||
{editor.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
flexShrink: 0,
|
||||
},
|
||||
splitButton: {
|
||||
flexDirection: "row",
|
||||
alignItems: "stretch",
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.borderAccent,
|
||||
overflow: "hidden",
|
||||
},
|
||||
splitButtonPrimary: {
|
||||
paddingLeft: theme.spacing[3],
|
||||
paddingRight: 10,
|
||||
paddingVertical: theme.spacing[1],
|
||||
justifyContent: "center",
|
||||
position: "relative",
|
||||
},
|
||||
splitButtonPrimaryHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
splitButtonPrimaryDisabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
splitButtonText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: theme.fontSize.sm * 1.5,
|
||||
color: theme.colors.foreground,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
splitButtonContent: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
splitButtonSpinnerOnly: {
|
||||
transform: [{ scale: 0.8 }],
|
||||
},
|
||||
splitButtonCaret: {
|
||||
width: 28,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderLeftWidth: theme.borderWidth[1],
|
||||
borderLeftColor: theme.colors.borderAccent,
|
||||
},
|
||||
splitButtonCaretHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
}));
|
||||
@@ -49,6 +49,7 @@ import { ExplorerSidebar } from "@/components/explorer-sidebar";
|
||||
import { SplitContainer } from "@/components/split-container";
|
||||
import { SourceControlPanelIcon } from "@/components/icons/source-control-panel-icon";
|
||||
import { WorkspaceGitActions } from "@/screens/workspace/workspace-git-actions";
|
||||
import { WorkspaceOpenInEditorButton } from "@/screens/workspace/workspace-open-in-editor-button";
|
||||
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
|
||||
@@ -2098,6 +2099,12 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
}
|
||||
right={
|
||||
<View style={styles.headerRight}>
|
||||
{!isMobile ? (
|
||||
<WorkspaceOpenInEditorButton
|
||||
serverId={normalizedServerId}
|
||||
cwd={normalizedWorkspaceId}
|
||||
/>
|
||||
) : null}
|
||||
{!isMobile && isGitCheckout ? (
|
||||
<>
|
||||
{workspaceDirectory ? (
|
||||
|
||||
@@ -39,10 +39,10 @@ describe("buildWorkspaceTabMenuEntries", () => {
|
||||
expect(entries.filter((entry) => entry.kind === "item").map((entry) => entry.label)).toEqual([
|
||||
"Copy resume command",
|
||||
"Copy agent id",
|
||||
"Reload agent",
|
||||
"Close to the left",
|
||||
"Close to the right",
|
||||
"Close other tabs",
|
||||
"Reload agent",
|
||||
"Close",
|
||||
]);
|
||||
});
|
||||
@@ -66,10 +66,10 @@ describe("buildWorkspaceTabMenuEntries", () => {
|
||||
expect(entries.filter((entry) => entry.kind === "item").map((entry) => entry.label)).toEqual([
|
||||
"Copy resume command",
|
||||
"Copy agent id",
|
||||
"Reload agent",
|
||||
"Close tabs above",
|
||||
"Close tabs below",
|
||||
"Close other tabs",
|
||||
"Reload agent",
|
||||
"Close",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -138,17 +138,6 @@ export function buildWorkspaceTabMenuEntries(
|
||||
void onCopyAgentId(agentId);
|
||||
},
|
||||
});
|
||||
entries.push({
|
||||
kind: "item",
|
||||
key: "reload-agent",
|
||||
label: "Reload agent",
|
||||
icon: "rotate-cw",
|
||||
tooltip: "Reload agent to update skills, MCPs or login status.",
|
||||
testID: `${menuTestIDBase}-reload-agent`,
|
||||
onSelect: () => {
|
||||
void onReloadAgent(agentId);
|
||||
},
|
||||
});
|
||||
entries.push({
|
||||
kind: "separator",
|
||||
key: "copy-separator",
|
||||
@@ -188,6 +177,20 @@ export function buildWorkspaceTabMenuEntries(
|
||||
void onCloseOtherTabs(tab.tabId);
|
||||
},
|
||||
});
|
||||
if (tab.target.kind === "agent") {
|
||||
const { agentId } = tab.target;
|
||||
entries.push({
|
||||
kind: "item",
|
||||
key: "reload-agent",
|
||||
label: "Reload agent",
|
||||
icon: "rotate-cw",
|
||||
tooltip: "Reload agent to update skills, MCPs or login status.",
|
||||
testID: `${menuTestIDBase}-reload-agent`,
|
||||
onSelect: () => {
|
||||
void onReloadAgent(agentId);
|
||||
},
|
||||
});
|
||||
}
|
||||
entries.push({
|
||||
kind: "item",
|
||||
key: "close",
|
||||
|
||||
@@ -66,6 +66,8 @@ interface PanelState {
|
||||
// File explorer settings (shared between mobile/desktop)
|
||||
explorerTab: ExplorerTab;
|
||||
explorerTabByCheckout: Record<string, ExplorerTab>;
|
||||
expandedPathsByWorkspace: Record<string, string[]>;
|
||||
diffExpandedPathsByWorkspace: Record<string, string[]>;
|
||||
activeExplorerCheckout: ExplorerCheckoutContext | null;
|
||||
sidebarWidth: number;
|
||||
explorerWidth: number;
|
||||
@@ -85,6 +87,8 @@ interface PanelState {
|
||||
// File explorer settings actions
|
||||
setExplorerTab: (tab: ExplorerTab) => void;
|
||||
setExplorerTabForCheckout: (params: ExplorerCheckoutContext & { tab: ExplorerTab }) => void;
|
||||
setExpandedPathsForWorkspace: (workspaceKey: string, paths: string[]) => void;
|
||||
setDiffExpandedPathsForWorkspace: (workspaceKey: string, paths: string[]) => void;
|
||||
activateExplorerTabForCheckout: (checkout: ExplorerCheckoutContext) => void;
|
||||
setActiveExplorerCheckout: (checkout: ExplorerCheckoutContext | null) => void;
|
||||
setSidebarWidth: (width: number) => void;
|
||||
@@ -142,6 +146,8 @@ export const usePanelStore = create<PanelState>()(
|
||||
// File explorer defaults
|
||||
explorerTab: "changes",
|
||||
explorerTabByCheckout: {},
|
||||
expandedPathsByWorkspace: {},
|
||||
diffExpandedPathsByWorkspace: {},
|
||||
activeExplorerCheckout: null,
|
||||
sidebarWidth: DEFAULT_SIDEBAR_WIDTH,
|
||||
explorerWidth: DEFAULT_EXPLORER_SIDEBAR_WIDTH,
|
||||
@@ -261,6 +267,17 @@ export const usePanelStore = create<PanelState>()(
|
||||
}
|
||||
return nextState;
|
||||
}),
|
||||
setExpandedPathsForWorkspace: (workspaceKey, paths) =>
|
||||
set((state) => ({
|
||||
expandedPathsByWorkspace: { ...state.expandedPathsByWorkspace, [workspaceKey]: paths },
|
||||
})),
|
||||
setDiffExpandedPathsForWorkspace: (workspaceKey, paths) =>
|
||||
set((state) => ({
|
||||
diffExpandedPathsByWorkspace: {
|
||||
...state.diffExpandedPathsByWorkspace,
|
||||
[workspaceKey]: paths,
|
||||
},
|
||||
})),
|
||||
activateExplorerTabForCheckout: (checkout) =>
|
||||
set((state) => ({
|
||||
activeExplorerCheckout: checkout,
|
||||
@@ -295,7 +312,7 @@ export const usePanelStore = create<PanelState>()(
|
||||
}),
|
||||
{
|
||||
name: "panel-state",
|
||||
version: 8,
|
||||
version: 10,
|
||||
storage: createJSONStorage(() => AsyncStorage),
|
||||
migrate: (persistedState, version) => {
|
||||
const state = persistedState as Partial<PanelState> & Record<string, unknown>;
|
||||
@@ -371,6 +388,22 @@ export const usePanelStore = create<PanelState>()(
|
||||
state.sidebarWidth = DEFAULT_SIDEBAR_WIDTH;
|
||||
}
|
||||
|
||||
if (
|
||||
version < 9 ||
|
||||
typeof state.expandedPathsByWorkspace !== "object" ||
|
||||
!state.expandedPathsByWorkspace
|
||||
) {
|
||||
state.expandedPathsByWorkspace = {};
|
||||
}
|
||||
|
||||
if (
|
||||
version < 10 ||
|
||||
typeof state.diffExpandedPathsByWorkspace !== "object" ||
|
||||
!state.diffExpandedPathsByWorkspace
|
||||
) {
|
||||
state.diffExpandedPathsByWorkspace = {};
|
||||
}
|
||||
|
||||
state.activeExplorerCheckout = null;
|
||||
|
||||
return state as PanelState;
|
||||
@@ -380,6 +413,8 @@ export const usePanelStore = create<PanelState>()(
|
||||
desktop: state.desktop,
|
||||
explorerTab: state.explorerTab,
|
||||
explorerTabByCheckout: state.explorerTabByCheckout,
|
||||
expandedPathsByWorkspace: state.expandedPathsByWorkspace,
|
||||
diffExpandedPathsByWorkspace: state.diffExpandedPathsByWorkspace,
|
||||
sidebarWidth: state.sidebarWidth,
|
||||
explorerWidth: state.explorerWidth,
|
||||
explorerSortOption: state.explorerSortOption,
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import type { WorkspaceDescriptorPayload } from "@server/shared/messages";
|
||||
|
||||
import {
|
||||
mergeWorkspaceSnapshotWithExisting,
|
||||
normalizeWorkspaceDescriptor,
|
||||
useSessionStore,
|
||||
type WorkspaceDescriptor,
|
||||
} from "./session-store";
|
||||
|
||||
function workspace(
|
||||
function createWorkspace(
|
||||
input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">,
|
||||
): WorkspaceDescriptor {
|
||||
return {
|
||||
@@ -17,7 +20,7 @@ function workspace(
|
||||
projectRootPath: input.projectRootPath ?? "/repo",
|
||||
workspaceDirectory: input.workspaceDirectory ?? "/repo",
|
||||
projectKind: input.projectKind ?? "git",
|
||||
workspaceKind: input.workspaceKind ?? "checkout",
|
||||
workspaceKind: input.workspaceKind ?? "local_checkout",
|
||||
name: input.name ?? "main",
|
||||
status: input.status ?? "done",
|
||||
activityAt: input.activityAt ?? null,
|
||||
@@ -99,11 +102,11 @@ describe("mergeWorkspaces", () => {
|
||||
store.initializeSession("test-server", null as unknown as DaemonClient);
|
||||
store.setWorkspaces(
|
||||
"test-server",
|
||||
new Map([["/repo/main", workspace({ id: "/repo/main", services: [] })]]),
|
||||
new Map([["/repo/main", createWorkspace({ id: "/repo/main", services: [] })]]),
|
||||
);
|
||||
|
||||
store.mergeWorkspaces("test-server", [
|
||||
workspace({
|
||||
createWorkspace({
|
||||
id: "/repo/main",
|
||||
services: [
|
||||
{
|
||||
@@ -130,3 +133,34 @@ describe("mergeWorkspaces", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeWorkspaceSnapshotWithExisting", () => {
|
||||
it("preserves the last known diff stat when a snapshot only has baseline null data", () => {
|
||||
const existing = createWorkspace({
|
||||
id: "/tmp/repo",
|
||||
diffStat: { additions: 4, deletions: 2 },
|
||||
});
|
||||
const incoming = createWorkspace({
|
||||
id: "/tmp/repo",
|
||||
diffStat: null,
|
||||
});
|
||||
|
||||
expect(mergeWorkspaceSnapshotWithExisting({ incoming, existing })).toEqual({
|
||||
...incoming,
|
||||
diffStat: { additions: 4, deletions: 2 },
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the incoming diff stat when the server provides a known value", () => {
|
||||
const existing = createWorkspace({
|
||||
id: "/tmp/repo",
|
||||
diffStat: { additions: 4, deletions: 2 },
|
||||
});
|
||||
const incoming = createWorkspace({
|
||||
id: "/tmp/repo",
|
||||
diffStat: { additions: 0, deletions: 0 },
|
||||
});
|
||||
|
||||
expect(mergeWorkspaceSnapshotWithExisting({ incoming, existing })).toEqual(incoming);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -147,6 +147,21 @@ export function normalizeWorkspaceDescriptor(
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeWorkspaceSnapshotWithExisting(input: {
|
||||
incoming: WorkspaceDescriptor;
|
||||
existing?: WorkspaceDescriptor | null;
|
||||
}): WorkspaceDescriptor {
|
||||
const { incoming, existing } = input;
|
||||
if (!existing || existing.id !== incoming.id) {
|
||||
return incoming;
|
||||
}
|
||||
|
||||
return {
|
||||
...incoming,
|
||||
diffStat: incoming.diffStat ?? existing.diffStat,
|
||||
};
|
||||
}
|
||||
|
||||
export type ExplorerEntryKind = "file" | "directory";
|
||||
export type ExplorerFileKind = "text" | "image" | "binary";
|
||||
export type ExplorerEncoding = "utf-8" | "base64" | "none";
|
||||
|
||||
@@ -111,6 +111,7 @@ const lightSemanticColors = {
|
||||
surface2: "#f4f4f5", // Elevated: badges, inputs, sheets (was zinc-200, now zinc-100)
|
||||
surface3: "#e4e4e7", // Highest elevation (was zinc-300, now zinc-200)
|
||||
surface4: "#d4d4d8", // Extra emphasis (was zinc-400, now zinc-300)
|
||||
surfaceDiffEmpty: "#f6f6f6", // Empty side of split diff rows, between surface1 and surface2 and biased toward surface2
|
||||
surfaceSidebar: "#f4f4f5", // Sidebar background (darker than main)
|
||||
surfaceWorkspace: "#ffffff", // Workspace main background
|
||||
|
||||
@@ -185,6 +186,7 @@ const darkSemanticColors = {
|
||||
surface2: "#272A29", // Elevated: badges, inputs, sheets
|
||||
surface3: "#434645", // Highest elevation
|
||||
surface4: "#595B5B", // Extra emphasis
|
||||
surfaceDiffEmpty: "#252827", // Empty side of split diff rows, between surface1 and surface2 and biased toward surface2
|
||||
surfaceSidebar: "#141716", // Sidebar background (darker than main)
|
||||
surfaceWorkspace: "#1E2120", // Workspace main background (surface1)
|
||||
|
||||
@@ -279,6 +281,10 @@ const commonTheme = {
|
||||
"4xl": 34,
|
||||
},
|
||||
|
||||
lineHeight: {
|
||||
diff: 22,
|
||||
},
|
||||
|
||||
iconSize: {
|
||||
xs: 12,
|
||||
sm: 14,
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildSplitDiffRows } from "./diff-layout";
|
||||
import type { ParsedDiffFile } from "@/hooks/use-checkout-diff-query";
|
||||
|
||||
function makeFile(lines: ParsedDiffFile["hunks"][number]["lines"]): ParsedDiffFile {
|
||||
return {
|
||||
path: "example.ts",
|
||||
isNew: false,
|
||||
isDeleted: false,
|
||||
additions: lines.filter((line) => line.type === "add").length,
|
||||
deletions: lines.filter((line) => line.type === "remove").length,
|
||||
status: "ok",
|
||||
hunks: [
|
||||
{
|
||||
oldStart: 10,
|
||||
oldCount: 4,
|
||||
newStart: 10,
|
||||
newCount: 5,
|
||||
lines,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildSplitDiffRows", () => {
|
||||
it("pairs replacement runs by index", () => {
|
||||
const rows = buildSplitDiffRows(
|
||||
makeFile([
|
||||
{ type: "header", content: "@@ -10,2 +10,2 @@" },
|
||||
{ type: "remove", content: "before one" },
|
||||
{ type: "remove", content: "before two" },
|
||||
{ type: "add", content: "after one" },
|
||||
{ type: "add", content: "after two" },
|
||||
]),
|
||||
);
|
||||
|
||||
expect(rows).toHaveLength(3);
|
||||
expect(rows[1]).toMatchObject({
|
||||
kind: "pair",
|
||||
left: { type: "remove", content: "before one", lineNumber: 10 },
|
||||
right: { type: "add", content: "after one", lineNumber: 10 },
|
||||
});
|
||||
expect(rows[2]).toMatchObject({
|
||||
kind: "pair",
|
||||
left: { type: "remove", content: "before two", lineNumber: 11 },
|
||||
right: { type: "add", content: "after two", lineNumber: 11 },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps unmatched additions on the right side only", () => {
|
||||
const rows = buildSplitDiffRows(
|
||||
makeFile([
|
||||
{ type: "header", content: "@@ -10,1 +10,2 @@" },
|
||||
{ type: "remove", content: "before" },
|
||||
{ type: "add", content: "after one" },
|
||||
{ type: "add", content: "after two" },
|
||||
]),
|
||||
);
|
||||
|
||||
expect(rows[2]).toMatchObject({
|
||||
kind: "pair",
|
||||
left: null,
|
||||
right: { type: "add", content: "after two", lineNumber: 11 },
|
||||
});
|
||||
});
|
||||
|
||||
it("duplicates context rows on both sides", () => {
|
||||
const rows = buildSplitDiffRows(
|
||||
makeFile([
|
||||
{ type: "header", content: "@@ -10,1 +10,1 @@" },
|
||||
{ type: "context", content: "same line" },
|
||||
]),
|
||||
);
|
||||
|
||||
expect(rows[1]).toMatchObject({
|
||||
kind: "pair",
|
||||
left: { type: "context", content: "same line", lineNumber: 10 },
|
||||
right: { type: "context", content: "same line", lineNumber: 10 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { DiffLine, ParsedDiffFile } from "@/hooks/use-checkout-diff-query";
|
||||
|
||||
export interface SplitDiffDisplayLine {
|
||||
type: DiffLine["type"];
|
||||
content: string;
|
||||
tokens?: DiffLine["tokens"];
|
||||
lineNumber: number | null;
|
||||
}
|
||||
|
||||
export type SplitDiffRow =
|
||||
| {
|
||||
kind: "header";
|
||||
content: string;
|
||||
}
|
||||
| {
|
||||
kind: "pair";
|
||||
left: SplitDiffDisplayLine | null;
|
||||
right: SplitDiffDisplayLine | null;
|
||||
};
|
||||
|
||||
function toDisplayLine(input: {
|
||||
line: DiffLine;
|
||||
oldLineNumber: number | null;
|
||||
newLineNumber: number | null;
|
||||
side: "left" | "right";
|
||||
}): SplitDiffDisplayLine | null {
|
||||
const { line, oldLineNumber, newLineNumber, side } = input;
|
||||
if (line.type === "header") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (line.type === "remove") {
|
||||
if (side !== "left") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: "remove",
|
||||
content: line.content,
|
||||
tokens: line.tokens,
|
||||
lineNumber: oldLineNumber,
|
||||
};
|
||||
}
|
||||
|
||||
if (line.type === "add") {
|
||||
if (side !== "right") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: "add",
|
||||
content: line.content,
|
||||
tokens: line.tokens,
|
||||
lineNumber: newLineNumber,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "context",
|
||||
content: line.content,
|
||||
tokens: line.tokens,
|
||||
lineNumber: side === "left" ? oldLineNumber : newLineNumber,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSplitDiffRows(file: ParsedDiffFile): SplitDiffRow[] {
|
||||
const rows: SplitDiffRow[] = [];
|
||||
|
||||
for (const hunk of file.hunks) {
|
||||
let oldLineNo = hunk.oldStart;
|
||||
let newLineNo = hunk.newStart;
|
||||
rows.push({
|
||||
kind: "header",
|
||||
content: hunk.lines[0]?.type === "header" ? hunk.lines[0].content : "@@",
|
||||
});
|
||||
|
||||
let pendingRemovals: Array<{ line: DiffLine; oldLineNumber: number }> = [];
|
||||
let pendingAdditions: Array<{ line: DiffLine; newLineNumber: number }> = [];
|
||||
|
||||
const flushPendingRows = () => {
|
||||
const pairCount = Math.max(pendingRemovals.length, pendingAdditions.length);
|
||||
for (let index = 0; index < pairCount; index += 1) {
|
||||
const removal = pendingRemovals[index] ?? null;
|
||||
const addition = pendingAdditions[index] ?? null;
|
||||
rows.push({
|
||||
kind: "pair",
|
||||
left: removal
|
||||
? toDisplayLine({
|
||||
line: removal.line,
|
||||
oldLineNumber: removal.oldLineNumber,
|
||||
newLineNumber: null,
|
||||
side: "left",
|
||||
})
|
||||
: null,
|
||||
right: addition
|
||||
? toDisplayLine({
|
||||
line: addition.line,
|
||||
oldLineNumber: null,
|
||||
newLineNumber: addition.newLineNumber,
|
||||
side: "right",
|
||||
})
|
||||
: null,
|
||||
});
|
||||
}
|
||||
pendingRemovals = [];
|
||||
pendingAdditions = [];
|
||||
};
|
||||
|
||||
for (const line of hunk.lines.slice(1)) {
|
||||
if (line.type === "remove") {
|
||||
pendingRemovals.push({ line, oldLineNumber: oldLineNo });
|
||||
oldLineNo += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.type === "add") {
|
||||
pendingAdditions.push({ line, newLineNumber: newLineNo });
|
||||
newLineNo += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
flushPendingRows();
|
||||
|
||||
if (line.type === "context") {
|
||||
rows.push({
|
||||
kind: "pair",
|
||||
left: toDisplayLine({
|
||||
line,
|
||||
oldLineNumber: oldLineNo,
|
||||
newLineNumber: newLineNo,
|
||||
side: "left",
|
||||
}),
|
||||
right: toDisplayLine({
|
||||
line,
|
||||
oldLineNumber: oldLineNo,
|
||||
newLineNumber: newLineNo,
|
||||
side: "right",
|
||||
}),
|
||||
});
|
||||
oldLineNo += 1;
|
||||
newLineNo += 1;
|
||||
}
|
||||
}
|
||||
|
||||
flushPendingRows();
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
Reference in New Issue
Block a user