Merge origin/main into show-image-previews-in-optimistic-messages

This commit is contained in:
Mohamed Boudra
2026-02-18 10:02:46 +07:00
159 changed files with 10110 additions and 1480 deletions
@@ -73,6 +73,7 @@ import {
normalizeAgentSnapshot,
} from "@/utils/agent-snapshots";
import { mergePendingCreateImages } from "@/utils/pending-create-images";
import { shouldClearAgentAttentionOnView } from "@/utils/agent-attention";
import type { FetchAgentsEntry } from "@server/client/daemon-client";
import {
DropdownMenu,
@@ -514,6 +515,9 @@ function AgentScreenContent({
const isConnected = useSessionStore(
(state) => state.sessions[serverId]?.connection.isConnected ?? false
);
const focusedAgentId = useSessionStore(
(state) => state.sessions[serverId]?.focusedAgentId ?? null
);
const { ensureAgentIsInitialized, refreshAgent } = useAgentInitialization(serverId);
const [missingAgentState, setMissingAgentState] = useState<MissingAgentState>({
kind: "idle",
@@ -894,25 +898,30 @@ function AgentScreenContent({
document.title = title;
}, [agent?.title]);
// Track previous agent status to detect completion while viewing
const previousStatusRef = useRef<string | null>(null);
// Clear attention when agent finishes while user is viewing this screen
// Clear attention as soon as the user is focused on this agent screen.
useEffect(() => {
if (!resolvedAgentId || !agent || !client) {
const clearAgentId = resolvedAgentId?.trim();
if (!clearAgentId || !client) {
return;
}
const previousStatus = previousStatusRef.current;
const currentStatus = agent.status;
previousStatusRef.current = currentStatus;
// If agent transitioned from running to idle while we're viewing,
// immediately clear attention since user witnessed the completion
if (previousStatus === "running" && currentStatus === "idle") {
client.clearAgentAttention(resolvedAgentId);
if (
!shouldClearAgentAttentionOnView({
agentId: clearAgentId,
focusedAgentId,
isConnected,
requiresAttention: agent?.requiresAttention,
})
) {
return;
}
}, [resolvedAgentId, agent?.status, client]);
client.clearAgentAttention(clearAgentId);
}, [
agent?.requiresAttention,
client,
focusedAgentId,
isConnected,
resolvedAgentId,
]);
const handleRefreshAgent = useCallback(() => {
if (!resolvedAgentId) {
@@ -14,8 +14,8 @@ import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyl
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
import Animated, { useAnimatedStyle, useSharedValue } from "react-native-reanimated";
import { Folder, GitBranch, Menu, PanelLeft } from "lucide-react-native";
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
import { Folder, GitBranch } from "lucide-react-native";
import { SidebarMenuToggle } from "@/components/headers/menu-header";
import { AgentInputArea } from "@/components/agent-input-area";
import { AgentStreamView } from "@/components/agent-stream-view";
import { AgentConfigRow, FormSelectTrigger } from "@/components/agent-form/agent-form-dropdowns";
@@ -23,15 +23,18 @@ import { Combobox } from "@/components/ui/combobox";
import { FileDropZone } from "@/components/file-drop-zone";
import { useQuery } from "@tanstack/react-query";
import { useAgentFormState, type CreateAgentInitialValues } from "@/hooks/use-agent-form-state";
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
import {
CHECKOUT_STATUS_STALE_TIME,
checkoutStatusQueryKey,
} from "@/hooks/use-checkout-status-query";
import { useAllAgentsList } from "@/hooks/use-all-agents-list";
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
import { buildBranchComboOptions, normalizeBranchOptionName } from "@/utils/branch-suggestions";
import { shortenPath } from "@/utils/shorten-path";
import { usePanelStore } from "@/stores/panel-store";
import { collectAgentWorkingDirectorySuggestions } from "@/utils/agent-working-directory-suggestions";
import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions";
import { useSessionStore } from "@/stores/session-store";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
@@ -128,9 +131,6 @@ export function DraftAgentScreen({
const insets = useSafeAreaInsets();
const { connectionStates } = useDaemonConnections();
const { daemons } = useDaemonRegistry();
const mobileView = usePanelStore((state) => state.mobileView);
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
const params = useLocalSearchParams<DraftAgentParams>();
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
@@ -229,11 +229,6 @@ export function DraftAgentScreen({
: undefined;
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isSidebarOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
const SidebarIcon = isMobile ? Menu : PanelLeft;
const sidebarIconColor = !isMobile && isSidebarOpen
? theme.colors.foreground
: theme.colors.foregroundMuted;
const [worktreeMode, setWorktreeMode] = useState<"none" | "create" | "attach">("none");
const [baseBranch, setBaseBranch] = useState("");
@@ -244,6 +239,8 @@ export function DraftAgentScreen({
const [isBranchOpen, setIsBranchOpen] = useState(false);
const [branchSearchQuery, setBranchSearchQuery] = useState("");
const [debouncedBranchSearchQuery, setDebouncedBranchSearchQuery] = useState("");
const [workingDirSearchQuery, setWorkingDirSearchQuery] = useState("");
const [debouncedWorkingDirSearchQuery, setDebouncedWorkingDirSearchQuery] = useState("");
const workingDirAnchorRef = useRef<View>(null);
const worktreeAnchorRef = useRef<View>(null);
const branchAnchorRef = useRef<View>(null);
@@ -258,6 +255,12 @@ export function DraftAgentScreen({
return () => clearTimeout(timer);
}, [branchSearchQuery]);
useEffect(() => {
const trimmed = workingDirSearchQuery.trim();
const timer = setTimeout(() => setDebouncedWorkingDirSearchQuery(trimmed), 180);
return () => clearTimeout(timer);
}, [workingDirSearchQuery]);
type CreateAttempt = {
messageId: string;
text: string;
@@ -320,6 +323,7 @@ export function DraftAgentScreen({
const sessionAgents = useSessionStore((state) =>
selectedServerId ? state.sessions[selectedServerId]?.agents : undefined
);
const { agents: allAgents } = useAllAgentsList({ serverId: selectedServerId });
const worktreePathLastCreatedAt = useMemo(() => {
const map = new Map<string, number>();
if (!sessionAgents) {
@@ -338,24 +342,23 @@ export function DraftAgentScreen({
return map;
}, [sessionAgents]);
const agentWorkingDirSuggestions = useMemo(() => {
if (!selectedServerId || !sessionAgents) {
return [];
}
const pathLastCreated = new Map<string, Date>();
sessionAgents.forEach((agent) => {
if (agent.cwd && !agent.cwd.includes(".paseo/worktrees")) {
const existing = pathLastCreated.get(agent.cwd);
if (!existing || agent.createdAt > existing) {
pathLastCreated.set(agent.cwd, agent.createdAt);
}
}
});
return Array.from(pathLastCreated.keys()).sort((a, b) => {
const aTime = pathLastCreated.get(a)!.getTime();
const bTime = pathLastCreated.get(b)!.getTime();
return bTime - aTime;
});
}, [selectedServerId, sessionAgents]);
const liveSources = sessionAgents
? Array.from(sessionAgents.values()).map((agent) => ({
cwd: agent.cwd,
createdAt: agent.createdAt,
lastActivityAt: agent.lastActivityAt,
}))
: [];
const fetchedSources = allAgents.map((agent) => ({
cwd: agent.cwd,
lastActivityAt: agent.lastActivityAt,
}));
return collectAgentWorkingDirectorySuggestions([
...liveSources,
...fetchedSources,
]);
}, [allAgents, sessionAgents]);
const sessionClient = useSessionStore((state) =>
selectedServerId ? state.sessions[selectedServerId]?.client ?? null : null
@@ -524,6 +527,36 @@ export function DraftAgentScreen({
staleTime: 15_000,
});
const directorySuggestionsQuery = useQuery({
queryKey: [
"directorySuggestions",
selectedServerId,
debouncedWorkingDirSearchQuery,
],
queryFn: async () => {
const client = sessionClient;
if (!client) {
throw new Error("Daemon client unavailable");
}
const payload = await client.getDirectorySuggestions({
query: debouncedWorkingDirSearchQuery,
limit: 50,
});
if (payload.error) {
throw new Error(payload.error);
}
return payload.directories ?? [];
},
enabled:
Boolean(debouncedWorkingDirSearchQuery) &&
Boolean(selectedServerId) &&
!daemonAvailabilityError &&
Boolean(sessionClient) &&
isConnected,
retry: false,
staleTime: 15_000,
});
const validateWorktreeName = useCallback(
(name: string): { valid: boolean; error?: string } => {
if (!name) {
@@ -657,6 +690,50 @@ export function DraftAgentScreen({
const selectedWorktreeLabel =
worktreeOptions.find((option) => option.path === selectedWorktreePath)?.label ?? "";
const hasWorkingDirectorySearch = debouncedWorkingDirSearchQuery.length > 0;
const workingDirSearchError =
directorySuggestionsQuery.error instanceof Error
? directorySuggestionsQuery.error.message
: null;
const workingDirSuggestionPaths = useMemo(
() =>
buildWorkingDirectorySuggestions({
recommendedPaths: agentWorkingDirSuggestions,
serverPaths: hasWorkingDirectorySearch ? (directorySuggestionsQuery.data ?? []) : [],
query: workingDirSearchQuery,
}),
[
agentWorkingDirSuggestions,
directorySuggestionsQuery.data,
hasWorkingDirectorySearch,
workingDirSearchQuery,
]
);
const workingDirComboOptions = useMemo(
() =>
workingDirSuggestionPaths.map((path) => ({
id: path,
label: shortenPath(path),
kind: "directory" as const,
})),
[workingDirSuggestionPaths]
);
const workingDirEmptyText = useMemo(() => {
if (hasWorkingDirectorySearch) {
if (workingDirSearchError) {
return "Failed to search directories on this host.";
}
return "No directories match your search.";
}
return agentWorkingDirSuggestions.length > 0
? "No agent directories match your search."
: "We'll suggest directories from agents on this host once they exist.";
}, [
agentWorkingDirSuggestions.length,
hasWorkingDirectorySearch,
workingDirSearchError,
]);
const displayWorkingDir = shortenPath(workingDir);
const worktreeTriggerValue =
worktreeMode === "create"
@@ -716,6 +793,33 @@ export function DraftAgentScreen({
const createAgentClient = useSessionStore((state) =>
selectedServerId ? state.sessions[selectedServerId]?.client ?? null : null
);
const draftCommandConfig = useMemo<DraftCommandConfig | undefined>(() => {
const cwd = (
isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir
).trim();
if (!cwd) {
return undefined;
}
return {
provider: selectedProvider,
cwd,
...(modeOptions.length > 0 && selectedMode !== "" ? { modeId: selectedMode } : {}),
...(selectedModel.trim() ? { model: selectedModel.trim() } : {}),
...(selectedThinkingOptionId.trim()
? { thinkingOptionId: selectedThinkingOptionId.trim() }
: {}),
};
}, [
isAttachWorktree,
modeOptions.length,
selectedMode,
selectedModel,
selectedProvider,
selectedThinkingOptionId,
selectedWorktreePath,
workingDir,
]);
const promptValue = machine.tag === "draft" ? machine.promptText : "";
const formErrorMessage = machine.tag === "draft" ? machine.errorMessage : "";
@@ -988,20 +1092,7 @@ export function DraftAgentScreen({
isMobile ? { paddingTop: insets.top + theme.spacing[2] } : null,
]}
>
<HeaderToggleButton
onPress={toggleAgentList}
tooltipLabel="Toggle sidebar"
tooltipKeys={["mod", "B"]}
tooltipSide="right"
testID="menu-button"
nativeID="menu-button"
accessible
accessibilityRole="button"
accessibilityLabel={isSidebarOpen ? "Close menu" : "Open menu"}
accessibilityState={{ expanded: isSidebarOpen }}
>
<SidebarIcon size={isMobile ? 20 : 16} color={sidebarIconColor} />
</HeaderToggleButton>
<SidebarMenuToggle />
</View>
<Animated.View style={[styles.contentContainer, animatedKeyboardStyle]}>
@@ -1133,21 +1224,16 @@ export function DraftAgentScreen({
/>
<Combobox
options={agentWorkingDirSuggestions.map((path) => ({
id: path,
label: shortenPath(path),
}))}
options={workingDirComboOptions}
value={workingDir}
onSelect={setWorkingDirFromUser}
searchPlaceholder="/path/to/project"
emptyText={
agentWorkingDirSuggestions.length > 0
? "No agent directories match your search."
: "We'll suggest directories from agents on this host once they exist."
}
onSearchQueryChange={setWorkingDirSearchQuery}
searchPlaceholder="Search directories..."
emptyText={workingDirEmptyText}
allowCustomValue
customValuePrefix="Use"
customValueDescription="Launch the agent in this directory"
customValuePrefix=""
customValueKind="directory"
optionsPosition="above-search"
title="Working directory"
open={isWorkingDirOpen}
onOpenChange={setIsWorkingDirOpen}
@@ -1193,6 +1279,7 @@ export function DraftAgentScreen({
onChangeText={(next) => dispatch({ type: "DRAFT_SET_PROMPT", text: next })}
autoFocus={machine.tag === "draft"}
onAddImages={handleAddImagesCallback}
commandDraftConfig={draftCommandConfig}
/>
</View>
</View>
+15 -23
View File
@@ -19,6 +19,7 @@ import { useDaemonRegistry, type HostProfile, type HostConnection } from "@/cont
import { useDaemonConnections, type ActiveConnection, type ConnectionStatus } from "@/contexts/daemon-connections-context";
import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons";
import { measureConnectionLatency } from "@/utils/test-daemon-connection";
import { confirmDialog } from "@/utils/confirm-dialog";
import { theme as defaultTheme } from "@/styles/theme";
import { MenuHeader } from "@/components/headers/menu-header";
import { useSessionStore } from "@/stores/session-store";
@@ -76,7 +77,6 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
letterSpacing: 0.6,
textTransform: "uppercase",
marginBottom: theme.spacing[3],
marginLeft: theme.spacing[1],
},
@@ -983,29 +983,21 @@ function HostDetailModal({
return;
}
if (Platform.OS === "web") {
const hasBrowserConfirm =
typeof globalThis !== "undefined" &&
typeof (globalThis as any).confirm === "function";
const confirmed = hasBrowserConfirm
? (globalThis as any).confirm(`Restart ${host.label}? ${restartConfirmationMessage}`)
: true;
if (confirmed) {
beginServerRestart();
void confirmDialog({
title: `Restart ${host.label}`,
message: restartConfirmationMessage,
confirmLabel: "Restart",
cancelLabel: "Cancel",
destructive: true,
}).then((confirmed) => {
if (!confirmed) {
return;
}
return;
}
Alert.alert(`Restart ${host.label}`, restartConfirmationMessage, [
{ text: "Cancel", style: "cancel" },
{
text: "Restart",
style: "destructive",
onPress: beginServerRestart,
},
]);
beginServerRestart();
}).catch((error) => {
console.error(`[Settings] Failed to open restart confirmation for ${host.label}`, error);
Alert.alert("Error", "Unable to open the restart confirmation dialog.");
});
}, [beginServerRestart, daemonClient, host, restartConfirmationMessage]);
// Status display