feat: add relay reconnect with grace period and branch suggestions
This commit is contained in:
@@ -45,7 +45,6 @@ import {
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||
import type { ConnectionStatus } from "@/contexts/daemon-connections-context";
|
||||
import { formatConnectionStatus } from "@/utils/daemons";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import type { Agent } from "@/contexts/session-context";
|
||||
@@ -74,6 +73,8 @@ import {
|
||||
|
||||
const DROPDOWN_WIDTH = 220;
|
||||
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
|
||||
const RECONNECT_NOTICE_DELAY_MS = 10_000;
|
||||
const CONNECTED_NOTICE_DURATION_MS = 2_500;
|
||||
|
||||
export function AgentReadyScreen({
|
||||
serverId,
|
||||
@@ -101,7 +102,6 @@ export function AgentReadyScreen({
|
||||
const isUnknownDaemon = Boolean(connectionServerId && !connection);
|
||||
const connectionStatus =
|
||||
connection?.status ?? (isUnknownDaemon ? "offline" : "idle");
|
||||
const connectionStatusLabel = formatConnectionStatus(connectionStatus);
|
||||
const lastConnectionError = connection?.lastError ?? null;
|
||||
|
||||
const handleBackToHome = useCallback(() => {
|
||||
@@ -152,7 +152,6 @@ export function AgentReadyScreen({
|
||||
onBack={handleBackToHome}
|
||||
serverLabel={serverLabel}
|
||||
connectionStatus={connectionStatus}
|
||||
connectionStatusLabel={connectionStatusLabel}
|
||||
lastError={lastConnectionError}
|
||||
isUnknownDaemon={isUnknownDaemon}
|
||||
/>
|
||||
@@ -164,6 +163,7 @@ export function AgentReadyScreen({
|
||||
<AgentScreenContent
|
||||
serverId={resolvedServerId}
|
||||
agentId={resolvedAgentId}
|
||||
connectionStatus={connectionStatus}
|
||||
/>
|
||||
</ExplorerSidebarAnimationProvider>
|
||||
);
|
||||
@@ -172,6 +172,7 @@ export function AgentReadyScreen({
|
||||
type AgentScreenContentProps = {
|
||||
serverId: string;
|
||||
agentId?: string;
|
||||
connectionStatus: ConnectionStatus;
|
||||
};
|
||||
|
||||
type MissingAgentState =
|
||||
@@ -194,6 +195,7 @@ function isNotFoundErrorMessage(message: string): boolean {
|
||||
function AgentScreenContent({
|
||||
serverId,
|
||||
agentId,
|
||||
connectionStatus,
|
||||
}: AgentScreenContentProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const toast = useToast();
|
||||
@@ -383,6 +385,11 @@ function AgentScreenContent({
|
||||
const [missingAgentState, setMissingAgentState] = useState<MissingAgentState>({
|
||||
kind: "idle",
|
||||
});
|
||||
const [showReconnectNotice, setShowReconnectNotice] = useState(false);
|
||||
const [dismissedReconnectNotice, setDismissedReconnectNotice] = useState(false);
|
||||
const [showConnectedNotice, setShowConnectedNotice] = useState(false);
|
||||
const reconnectNoticeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const connectedNoticeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const initAttemptTokenRef = useRef(0);
|
||||
const setFocusedAgentId = useCallback(
|
||||
(agentId: string | null) => {
|
||||
@@ -410,6 +417,56 @@ function AgentScreenContent({
|
||||
const agentModel = extractAgentModel(agent);
|
||||
const modelDisplayValue = agentModel ?? "Unknown";
|
||||
|
||||
useEffect(() => {
|
||||
if (reconnectNoticeTimeoutRef.current) {
|
||||
clearTimeout(reconnectNoticeTimeoutRef.current);
|
||||
reconnectNoticeTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
if (connectionStatus === "online") {
|
||||
if (showReconnectNotice || dismissedReconnectNotice) {
|
||||
setShowConnectedNotice(true);
|
||||
}
|
||||
setShowReconnectNotice(false);
|
||||
setDismissedReconnectNotice(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setShowConnectedNotice(false);
|
||||
if (!showReconnectNotice && !dismissedReconnectNotice) {
|
||||
reconnectNoticeTimeoutRef.current = setTimeout(() => {
|
||||
setShowReconnectNotice(true);
|
||||
}, RECONNECT_NOTICE_DELAY_MS);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (reconnectNoticeTimeoutRef.current) {
|
||||
clearTimeout(reconnectNoticeTimeoutRef.current);
|
||||
reconnectNoticeTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [connectionStatus, dismissedReconnectNotice, showReconnectNotice]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showConnectedNotice) {
|
||||
if (connectedNoticeTimeoutRef.current) {
|
||||
clearTimeout(connectedNoticeTimeoutRef.current);
|
||||
connectedNoticeTimeoutRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
connectedNoticeTimeoutRef.current = setTimeout(() => {
|
||||
setShowConnectedNotice(false);
|
||||
connectedNoticeTimeoutRef.current = null;
|
||||
}, CONNECTED_NOTICE_DURATION_MS);
|
||||
return () => {
|
||||
if (connectedNoticeTimeoutRef.current) {
|
||||
clearTimeout(connectedNoticeTimeoutRef.current);
|
||||
connectedNoticeTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [showConnectedNotice]);
|
||||
|
||||
// Checkout status for header subtitle
|
||||
const checkoutStatusQuery = useCheckoutStatusQuery({
|
||||
serverId,
|
||||
@@ -902,6 +959,47 @@ function AgentScreenContent({
|
||||
}
|
||||
/>
|
||||
|
||||
{(showReconnectNotice || showConnectedNotice) && (
|
||||
<View
|
||||
style={[
|
||||
styles.connectionNotice,
|
||||
showReconnectNotice
|
||||
? styles.connectionNoticeReconnecting
|
||||
: styles.connectionNoticeConnected,
|
||||
]}
|
||||
>
|
||||
{showConnectedNotice ? (
|
||||
<CheckCircle2
|
||||
size={14}
|
||||
color={theme.colors.palette.green[600]}
|
||||
/>
|
||||
) : null}
|
||||
<Text
|
||||
style={[
|
||||
styles.connectionNoticeText,
|
||||
showReconnectNotice
|
||||
? styles.connectionNoticeTextReconnecting
|
||||
: styles.connectionNoticeTextConnected,
|
||||
]}
|
||||
>
|
||||
{showReconnectNotice ? "Reconnecting..." : "Connected"}
|
||||
</Text>
|
||||
{showReconnectNotice ? (
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
setShowReconnectNotice(false);
|
||||
setDismissedReconnectNotice(true);
|
||||
}}
|
||||
style={styles.connectionNoticeDismiss}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Dismiss reconnecting notice"
|
||||
>
|
||||
<Text style={styles.connectionNoticeDismissText}>Dismiss</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Content Area with Keyboard Animation */}
|
||||
<View style={styles.contentContainer}>
|
||||
{shouldBlockForHistorySync ? (
|
||||
@@ -967,14 +1065,12 @@ function AgentSessionUnavailableState({
|
||||
onBack,
|
||||
serverLabel,
|
||||
connectionStatus,
|
||||
connectionStatusLabel,
|
||||
lastError,
|
||||
isUnknownDaemon = false,
|
||||
}: {
|
||||
onBack: () => void;
|
||||
serverLabel: string;
|
||||
connectionStatus: ConnectionStatus;
|
||||
connectionStatusLabel: string;
|
||||
lastError: string | null;
|
||||
isUnknownDaemon?: boolean;
|
||||
}) {
|
||||
@@ -1015,11 +1111,10 @@ function AgentSessionUnavailableState({
|
||||
) : (
|
||||
<>
|
||||
<Text style={styles.offlineTitle}>
|
||||
{serverLabel} is currently {connectionStatusLabel.toLowerCase()}.
|
||||
Reconnecting to {serverLabel}...
|
||||
</Text>
|
||||
<Text style={styles.offlineDescription}>
|
||||
We'll reconnect automatically and show this agent as soon as the
|
||||
host comes back online.
|
||||
We will show this agent again as soon as the host is reachable.
|
||||
</Text>
|
||||
{lastError ? (
|
||||
<Text style={styles.offlineDetails}>{lastError}</Text>
|
||||
@@ -1049,6 +1144,49 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flex: 1,
|
||||
overflow: "hidden",
|
||||
},
|
||||
connectionNotice: {
|
||||
marginHorizontal: theme.spacing[4],
|
||||
marginTop: theme.spacing[1],
|
||||
marginBottom: theme.spacing[2],
|
||||
minHeight: 32,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
connectionNoticeReconnecting: {
|
||||
backgroundColor: `${theme.colors.palette.yellow[400]}22`,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.palette.yellow[400],
|
||||
},
|
||||
connectionNoticeConnected: {
|
||||
backgroundColor: theme.colors.palette.green[100],
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.palette.green[400],
|
||||
},
|
||||
connectionNoticeText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
connectionNoticeTextReconnecting: {
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
connectionNoticeTextConnected: {
|
||||
color: theme.colors.palette.green[800],
|
||||
},
|
||||
connectionNoticeDismiss: {
|
||||
marginLeft: "auto",
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
connectionNoticeDismissText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
||||
import { formatConnectionStatus } from "@/utils/daemons";
|
||||
import { buildBranchComboOptions, normalizeBranchOptionName } from "@/utils/branch-suggestions";
|
||||
import { shortenPath } from "@/utils/shorten-path";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
@@ -249,6 +250,8 @@ export function DraftAgentScreen({
|
||||
const [isWorkingDirOpen, setIsWorkingDirOpen] = useState(false);
|
||||
const [isWorktreePickerOpen, setIsWorktreePickerOpen] = useState(false);
|
||||
const [isBranchOpen, setIsBranchOpen] = useState(false);
|
||||
const [branchSearchQuery, setBranchSearchQuery] = useState("");
|
||||
const [debouncedBranchSearchQuery, setDebouncedBranchSearchQuery] = useState("");
|
||||
const hostAnchorRef = useRef<View>(null);
|
||||
const workingDirAnchorRef = useRef<View>(null);
|
||||
const worktreeAnchorRef = useRef<View>(null);
|
||||
@@ -258,6 +261,12 @@ export function DraftAgentScreen({
|
||||
const updatePendingAgentId = useCreateFlowStore((state) => state.updateAgentId);
|
||||
const clearPendingCreateAttempt = useCreateFlowStore((state) => state.clear);
|
||||
|
||||
useEffect(() => {
|
||||
const trimmed = branchSearchQuery.trim();
|
||||
const timer = setTimeout(() => setDebouncedBranchSearchQuery(trimmed), 180);
|
||||
return () => clearTimeout(timer);
|
||||
}, [branchSearchQuery]);
|
||||
|
||||
type CreateAttempt = {
|
||||
messageId: string;
|
||||
text: string;
|
||||
@@ -489,6 +498,40 @@ export function DraftAgentScreen({
|
||||
? "Select a worktree to attach"
|
||||
: null;
|
||||
|
||||
const branchSuggestionsQuery = useQuery({
|
||||
queryKey: [
|
||||
"branchSuggestions",
|
||||
selectedServerId,
|
||||
trimmedWorkingDir,
|
||||
debouncedBranchSearchQuery,
|
||||
],
|
||||
queryFn: async () => {
|
||||
const client = sessionClient;
|
||||
if (!client) {
|
||||
throw new Error("Daemon client unavailable");
|
||||
}
|
||||
const payload = await client.getBranchSuggestions({
|
||||
cwd: trimmedWorkingDir || ".",
|
||||
query: debouncedBranchSearchQuery || undefined,
|
||||
limit: 50,
|
||||
});
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
return payload.branches ?? [];
|
||||
},
|
||||
enabled:
|
||||
isCreateWorktree &&
|
||||
isGitDirectory &&
|
||||
!isNonGitDirectory &&
|
||||
Boolean(trimmedWorkingDir) &&
|
||||
!repoAvailabilityError &&
|
||||
Boolean(sessionClient) &&
|
||||
isConnected,
|
||||
retry: false,
|
||||
staleTime: 15_000,
|
||||
});
|
||||
|
||||
const validateWorktreeName = useCallback(
|
||||
(name: string): { valid: boolean; error?: string } => {
|
||||
if (!name) {
|
||||
@@ -658,21 +701,36 @@ export function DraftAgentScreen({
|
||||
);
|
||||
|
||||
const branchComboOptions = useMemo(() => {
|
||||
const branchSet = new Set<string>();
|
||||
const currentBranch = checkout?.isGit ? checkout.currentBranch?.trim() : null;
|
||||
if (currentBranch && currentBranch !== "HEAD") {
|
||||
branchSet.add(currentBranch);
|
||||
const options = buildBranchComboOptions({
|
||||
suggestedBranches: branchSuggestionsQuery.data ?? [],
|
||||
currentBranch: checkout?.isGit ? checkout.currentBranch : null,
|
||||
baseRef: checkout?.isGit ? checkout.baseRef : null,
|
||||
typedBaseBranch: baseBranch,
|
||||
worktreeBranchLabels: worktreeOptions.map((option) => option.label),
|
||||
});
|
||||
|
||||
const normalizedQuery = normalizeBranchOptionName(branchSearchQuery)?.toLowerCase() ?? "";
|
||||
if (!normalizedQuery) {
|
||||
return options;
|
||||
}
|
||||
if (baseBranch.trim()) {
|
||||
branchSet.add(baseBranch.trim());
|
||||
}
|
||||
for (const option of worktreeOptions) {
|
||||
if (option.label) {
|
||||
branchSet.add(option.label);
|
||||
|
||||
return options.sort((a, b) => {
|
||||
const aLower = a.label.toLowerCase();
|
||||
const bLower = b.label.toLowerCase();
|
||||
const aPrefix = aLower.startsWith(normalizedQuery);
|
||||
const bPrefix = bLower.startsWith(normalizedQuery);
|
||||
if (aPrefix !== bPrefix) {
|
||||
return aPrefix ? -1 : 1;
|
||||
}
|
||||
}
|
||||
return Array.from(branchSet).map((name) => ({ id: name, label: name }));
|
||||
}, [baseBranch, checkout, worktreeOptions]);
|
||||
return aLower.localeCompare(bLower);
|
||||
});
|
||||
}, [
|
||||
baseBranch,
|
||||
branchSearchQuery,
|
||||
branchSuggestionsQuery.data,
|
||||
checkout,
|
||||
worktreeOptions,
|
||||
]);
|
||||
|
||||
const createAgentClient = useSessionStore((state) =>
|
||||
selectedServerId ? state.sessions[selectedServerId]?.client ?? null : null
|
||||
@@ -1130,13 +1188,19 @@ export function DraftAgentScreen({
|
||||
options={branchComboOptions}
|
||||
value={baseBranch}
|
||||
onSelect={handleBaseBranchChange}
|
||||
onSearchQueryChange={setBranchSearchQuery}
|
||||
searchPlaceholder="Choose a base branch..."
|
||||
allowCustomValue
|
||||
customValuePrefix="Use"
|
||||
customValueDescription="Use this branch name"
|
||||
title="Select base branch"
|
||||
open={isBranchOpen}
|
||||
onOpenChange={setIsBranchOpen}
|
||||
onOpenChange={(nextOpen) => {
|
||||
setIsBranchOpen(nextOpen);
|
||||
if (!nextOpen) {
|
||||
setBranchSearchQuery("");
|
||||
}
|
||||
}}
|
||||
anchorRef={branchAnchorRef}
|
||||
/>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user