feat: add relay reconnect with grace period and branch suggestions

This commit is contained in:
Mohamed Boudra
2026-02-13 09:46:40 +07:00
parent 837d0fb387
commit bf432b3d3a
23 changed files with 1137 additions and 103 deletions
@@ -276,13 +276,11 @@ export function AgentInputArea({
imageAttachments?: ImageAttachment[],
forceSend?: boolean
) {
const socketConnected = isConnected;
const trimmedMessage = message.trim();
if (!trimmedMessage) return;
// When the parent controls submission (e.g. draft agent creation), let it
// decide what to do even if the socket is currently disconnected (so we
// don't no-op and lose deterministic error handling in the UI/tests).
if (!onSubmitMessageRef.current && !socketConnected) return;
if (!sendAgentMessageRef.current && !onSubmitMessageRef.current) return;
if (agent?.status === "running" && !forceSend) {
@@ -485,7 +483,7 @@ export function AgentInputArea({
async function handleSendQueuedNow(id: string) {
const item = queuedMessages.find((q) => q.id === id);
if (!item || !isConnected) return;
if (!item) return;
if (!sendAgentMessageRef.current && !onSubmitMessageRef.current) return;
updateQueue((current) => current.filter((q) => q.id !== id));
+10 -12
View File
@@ -731,7 +731,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
scrollEnabled={IS_WEB ? inputHeight >= MAX_INPUT_HEIGHT : true}
onContentSizeChange={handleContentSizeChange}
editable={
!isDictating && !isRealtimeVoiceForCurrentAgent && isConnected && !disabled
!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled
}
onKeyPress={
shouldHandleDesktopSubmit ? handleDesktopKeyPress : undefined
@@ -836,24 +836,22 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
)}
{shouldShowSendButton && (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={handleSendMessage}
disabled={
!isConnected ||
<TooltipTrigger
onPress={handleSendMessage}
disabled={
isSubmitDisabled ||
isSubmitLoading ||
disabled
}
accessibilityLabel={isAgentRunning ? "Send and interrupt" : "Send message"}
accessibilityRole="button"
style={[
styles.sendButton,
(!isConnected ||
isSubmitDisabled ||
accessibilityLabel={isAgentRunning ? "Send and interrupt" : "Send message"}
accessibilityRole="button"
style={[
styles.sendButton,
(isSubmitDisabled ||
isSubmitLoading ||
disabled) &&
styles.buttonDisabled,
]}
]}
>
{isSubmitLoading ? (
<ActivityIndicator size="small" color="white" />
@@ -183,7 +183,7 @@ function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
return (
<SessionProvider
key={`${daemon.serverId}:${activeUrl}`}
key={daemon.serverId}
serverUrl={activeUrl}
serverId={daemon.serverId}
activeConnection={active?.activeConnection ?? null}
+15 -5
View File
@@ -35,6 +35,7 @@ export interface ComboboxProps {
options: ComboboxOption[];
value: string;
onSelect: (id: string) => void;
onSearchQueryChange?: (query: string) => void;
placeholder?: string;
searchPlaceholder?: string;
emptyText?: string;
@@ -156,6 +157,7 @@ export function Combobox({
options,
value,
onSelect,
onSearchQueryChange,
placeholder = "Search...",
searchPlaceholder,
emptyText = "No options match your search.",
@@ -191,16 +193,24 @@ export function Combobox({
[isControlled, onOpenChange]
);
const setSearchQueryWithCallback = useCallback(
(nextQuery: string) => {
setSearchQuery(nextQuery);
onSearchQueryChange?.(nextQuery);
},
[onSearchQueryChange]
);
const handleClose = useCallback(() => {
setOpen(false);
setSearchQuery("");
}, [setOpen]);
setSearchQueryWithCallback("");
}, [setOpen, setSearchQueryWithCallback]);
useEffect(() => {
if (isOpen) {
setSearchQuery("");
setSearchQueryWithCallback("");
}
}, [isOpen]);
}, [isOpen, setSearchQueryWithCallback]);
const collisionPadding = useMemo(() => {
const basePadding = 16;
@@ -426,7 +436,7 @@ export function Combobox({
<SearchInput
placeholder={searchPlaceholder ?? placeholder}
value={searchQuery}
onChangeText={setSearchQuery}
onChangeText={setSearchQueryWithCallback}
onSubmitEditing={handleSubmitSearch}
autoFocus={!isMobile}
/>
@@ -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}
/>
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { buildBranchComboOptions, normalizeBranchOptionName } from "./branch-suggestions";
describe("normalizeBranchOptionName", () => {
it("normalizes local and origin-prefixed refs", () => {
expect(normalizeBranchOptionName("refs/heads/main")).toBe("main");
expect(normalizeBranchOptionName("refs/remotes/origin/main")).toBe("main");
expect(normalizeBranchOptionName("origin/feature/test")).toBe("feature/test");
expect(normalizeBranchOptionName("feature/test")).toBe("feature/test");
});
it("filters out empty values and HEAD", () => {
expect(normalizeBranchOptionName("")).toBeNull();
expect(normalizeBranchOptionName(" ")).toBeNull();
expect(normalizeBranchOptionName("HEAD")).toBeNull();
expect(normalizeBranchOptionName("origin/HEAD")).toBeNull();
});
});
describe("buildBranchComboOptions", () => {
it("merges branch sources and de-duplicates normalized names", () => {
const options = buildBranchComboOptions({
suggestedBranches: ["origin/main", "refs/remotes/origin/main", "feature/a"],
currentBranch: "refs/heads/feature/a",
baseRef: "origin/main",
typedBaseBranch: "main",
worktreeBranchLabels: ["refs/heads/release/next"],
});
expect(options).toEqual([
{ id: "main", label: "main" },
{ id: "feature/a", label: "feature/a" },
{ id: "release/next", label: "release/next" },
]);
});
});
@@ -0,0 +1,51 @@
export type BranchComboOption = {
id: string;
label: string;
};
export function normalizeBranchOptionName(input: string | null | undefined): string | null {
const trimmed = input?.trim();
if (!trimmed || trimmed === "HEAD") {
return null;
}
let normalized = trimmed;
if (normalized.startsWith("refs/heads/")) {
normalized = normalized.slice("refs/heads/".length);
} else if (normalized.startsWith("refs/remotes/")) {
normalized = normalized.slice("refs/remotes/".length);
}
if (normalized.startsWith("origin/")) {
normalized = normalized.slice("origin/".length);
}
return normalized.length > 0 && normalized !== "HEAD" ? normalized : null;
}
export function buildBranchComboOptions(input: {
suggestedBranches?: string[];
currentBranch?: string | null;
baseRef?: string | null;
typedBaseBranch?: string | null;
worktreeBranchLabels?: string[];
}): BranchComboOption[] {
const branchSet = new Set<string>();
const addBranch = (name: string | null | undefined) => {
const normalized = normalizeBranchOptionName(name);
if (normalized) {
branchSet.add(normalized);
}
};
for (const branch of input.suggestedBranches ?? []) {
addBranch(branch);
}
addBranch(input.currentBranch ?? null);
addBranch(input.baseRef ?? null);
addBranch(input.typedBaseBranch ?? null);
for (const label of input.worktreeBranchLabels ?? []) {
addBranch(label);
}
return Array.from(branchSet).map((name) => ({ id: name, label: name }));
}