Refine agent list attention handling
This commit is contained in:
@@ -39,7 +39,6 @@ import { markScrollInvestigationRender } from '@/utils/scroll-jank-investigation
|
||||
import { useKeyboardShiftStyle } from '@/hooks/use-keyboard-shift-style'
|
||||
import { useKeyboardActionHandler } from '@/hooks/use-keyboard-action-handler'
|
||||
import type { KeyboardActionDefinition } from '@/keyboard/keyboard-action-dispatcher'
|
||||
import { shouldClearAgentAttention } from '@/utils/agent-attention'
|
||||
|
||||
type QueuedMessage = {
|
||||
id: string
|
||||
@@ -67,11 +66,15 @@ interface AgentInputAreaProps {
|
||||
/** Called when a message is about to be sent (any path: keyboard, dictation, queued). */
|
||||
onMessageSent?: () => void
|
||||
onComposerHeightChange?: (height: number) => void
|
||||
onAttentionInputFocus?: () => void
|
||||
onAttentionPromptSend?: () => void
|
||||
/** Controlled status controls rendered in input area (draft flows). */
|
||||
statusControls?: DraftAgentStatusBarProps
|
||||
}
|
||||
|
||||
const EMPTY_ARRAY: readonly QueuedMessage[] = []
|
||||
const DESKTOP_MESSAGE_PLACEHOLDER = 'Message the agent, tag @files, or use /commands and /skills'
|
||||
const MOBILE_MESSAGE_PLACEHOLDER = 'Message, @files, /commands'
|
||||
|
||||
export function AgentInputArea({
|
||||
agentId,
|
||||
@@ -87,6 +90,8 @@ export function AgentInputArea({
|
||||
commandDraftConfig,
|
||||
onMessageSent,
|
||||
onComposerHeightChange,
|
||||
onAttentionInputFocus,
|
||||
onAttentionPromptSend,
|
||||
statusControls,
|
||||
}: AgentInputAreaProps) {
|
||||
markScrollInvestigationRender(`AgentInputArea:${serverId}:${agentId}`)
|
||||
@@ -127,6 +132,9 @@ export function AgentInputArea({
|
||||
Platform.OS === 'web' &&
|
||||
UnistylesRuntime.breakpoint !== 'xs' &&
|
||||
UnistylesRuntime.breakpoint !== 'sm'
|
||||
const messagePlaceholder = isDesktopWebBreakpoint
|
||||
? DESKTOP_MESSAGE_PLACEHOLDER
|
||||
: MOBILE_MESSAGE_PLACEHOLDER
|
||||
const userInput = value ?? internalInput
|
||||
const setUserInput = onChangeText ?? setInternalInput
|
||||
const [cursorIndex, setCursorIndex] = useState(0)
|
||||
@@ -243,18 +251,9 @@ export function AgentInputArea({
|
||||
messageId: clientMessageId,
|
||||
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
|
||||
})
|
||||
if (
|
||||
shouldClearAgentAttention({
|
||||
agentId,
|
||||
isConnected,
|
||||
requiresAttention: agent?.requiresAttention,
|
||||
attentionReason: agent?.attentionReason,
|
||||
})
|
||||
) {
|
||||
client.clearAgentAttention(agentId)
|
||||
}
|
||||
onAttentionPromptSend?.()
|
||||
}
|
||||
}, [agent?.attentionReason, agent?.requiresAttention, client, isConnected, serverId, setAgentStreamTail, setAgentStreamHead])
|
||||
}, [client, onAttentionPromptSend, serverId, setAgentStreamTail, setAgentStreamHead])
|
||||
|
||||
useEffect(() => {
|
||||
onSubmitMessageRef.current = onSubmitMessage
|
||||
@@ -808,7 +807,7 @@ export function AgentInputArea({
|
||||
onRemoveImage={handleRemoveImage}
|
||||
client={client}
|
||||
isReadyForDictation={isDictationReady}
|
||||
placeholder="Message the agent, tag @files, or use /commands and /skills"
|
||||
placeholder={messagePlaceholder}
|
||||
autoFocus={autoFocus && isDesktopWebBreakpoint}
|
||||
autoFocusKey={`${serverId}:${agentId}`}
|
||||
disabled={isSubmitLoading}
|
||||
@@ -824,7 +823,12 @@ export function AgentInputArea({
|
||||
onSelectionChange={(selection) => {
|
||||
setCursorIndex(selection.start)
|
||||
}}
|
||||
onFocusChange={setIsMessageInputFocused}
|
||||
onFocusChange={(focused) => {
|
||||
setIsMessageInputFocused(focused)
|
||||
if (focused) {
|
||||
onAttentionInputFocus?.()
|
||||
}
|
||||
}}
|
||||
onHeightChange={onComposerHeightChange}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -4,37 +4,23 @@ import {
|
||||
Pressable,
|
||||
Modal,
|
||||
RefreshControl,
|
||||
SectionList,
|
||||
type ViewToken,
|
||||
type SectionListRenderItem,
|
||||
FlatList,
|
||||
type ListRenderItem,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useCallback, useMemo, useState, type ReactElement } from "react";
|
||||
import { router, usePathname, type Href } from "expo-router";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import { shortenPath } from "@/utils/shorten-path";
|
||||
import { deriveBranchLabel, deriveProjectPath } from "@/utils/agent-display-info";
|
||||
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
getHostRuntimeStore,
|
||||
isHostRuntimeConnected,
|
||||
} from "@/runtime/host-runtime";
|
||||
import { AgentStatusDot } from "@/components/agent-status-dot";
|
||||
import {
|
||||
CHECKOUT_STATUS_STALE_TIME,
|
||||
checkoutStatusQueryKey,
|
||||
useCheckoutStatusCacheOnly,
|
||||
} from "@/hooks/use-checkout-status-query";
|
||||
import {
|
||||
buildAgentNavigationKey,
|
||||
startNavigationTiming,
|
||||
} from "@/utils/navigation-timing";
|
||||
import {
|
||||
buildHostWorkspaceAgentRoute,
|
||||
} from "@/utils/host-routes";
|
||||
import { buildHostWorkspaceAgentRoute } from "@/utils/host-routes";
|
||||
|
||||
interface AgentListProps {
|
||||
agents: AggregatedAgent[];
|
||||
@@ -52,6 +38,25 @@ interface AgentListSection {
|
||||
data: AggregatedAgent[];
|
||||
}
|
||||
|
||||
type SessionColumnKey = "session" | "project" | "host" | "status" | "updated";
|
||||
|
||||
interface SessionColumnDefinition {
|
||||
key: SessionColumnKey;
|
||||
label: string;
|
||||
flex: number;
|
||||
align?: "left" | "right";
|
||||
mobile?: boolean;
|
||||
requiresMultiHost?: boolean;
|
||||
}
|
||||
|
||||
const SESSION_COLUMNS: SessionColumnDefinition[] = [
|
||||
{ key: "session", label: "Session", flex: 2.3, mobile: true },
|
||||
{ key: "project", label: "Project", flex: 2.6 },
|
||||
{ key: "host", label: "Host", flex: 1.2, requiresMultiHost: true },
|
||||
{ key: "status", label: "Status", flex: 1.2, mobile: true },
|
||||
{ key: "updated", label: "Updated", flex: 1, align: "right", mobile: true },
|
||||
];
|
||||
|
||||
function deriveDateSectionLabel(lastActivityAt: Date): string {
|
||||
const now = new Date();
|
||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
@@ -80,76 +85,290 @@ function deriveDateSectionLabel(lastActivityAt: Date): string {
|
||||
return "Older";
|
||||
}
|
||||
|
||||
interface AgentListRowProps {
|
||||
agent: AggregatedAgent;
|
||||
selectedAgentId?: string;
|
||||
showCheckoutInfo: boolean;
|
||||
onPress: (agent: AggregatedAgent) => void;
|
||||
onLongPress: (agent: AggregatedAgent) => void;
|
||||
function formatStatusLabel(status: AggregatedAgent["status"]): string {
|
||||
switch (status) {
|
||||
case "initializing":
|
||||
return "Starting";
|
||||
case "idle":
|
||||
return "Idle";
|
||||
case "running":
|
||||
return "Running";
|
||||
case "error":
|
||||
return "Error";
|
||||
case "closed":
|
||||
return "Closed";
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
function AgentListRow({
|
||||
function getVisibleColumns(input: {
|
||||
isMobile: boolean;
|
||||
showHostColumn: boolean;
|
||||
}): SessionColumnDefinition[] {
|
||||
return SESSION_COLUMNS.filter((column) => {
|
||||
if (!input.showHostColumn && column.requiresMultiHost) {
|
||||
return false;
|
||||
}
|
||||
if (input.isMobile && !column.mobile) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function SessionCell({
|
||||
align = "left",
|
||||
flex,
|
||||
children,
|
||||
}: {
|
||||
align?: "left" | "right";
|
||||
flex: number;
|
||||
children: ReactElement;
|
||||
}) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.cell,
|
||||
{ flex },
|
||||
align === "right" ? styles.cellRight : styles.cellLeft,
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionBadge({
|
||||
label,
|
||||
tone = "neutral",
|
||||
}: {
|
||||
label: string;
|
||||
tone?: "neutral" | "warning" | "danger";
|
||||
}) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.badge,
|
||||
tone === "warning" && styles.badgeWarning,
|
||||
tone === "danger" && styles.badgeDanger,
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.badgeText,
|
||||
tone === "warning" && styles.badgeTextWarning,
|
||||
tone === "danger" && styles.badgeTextDanger,
|
||||
]}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionTableRow({
|
||||
agent,
|
||||
columns,
|
||||
isMobile,
|
||||
selectedAgentId,
|
||||
showCheckoutInfo,
|
||||
onPress,
|
||||
onLongPress,
|
||||
}: AgentListRowProps) {
|
||||
}: {
|
||||
agent: AggregatedAgent;
|
||||
columns: SessionColumnDefinition[];
|
||||
isMobile: boolean;
|
||||
selectedAgentId?: string;
|
||||
onPress: (agent: AggregatedAgent) => void;
|
||||
onLongPress: (agent: AggregatedAgent) => void;
|
||||
}) {
|
||||
const timeAgo = formatTimeAgo(agent.lastActivityAt);
|
||||
const agentKey = `${agent.serverId}:${agent.id}`;
|
||||
const isSelected = selectedAgentId === agentKey;
|
||||
const archivedLabel = agent.archivedAt ? "Archived" : null;
|
||||
const checkoutQuery = useCheckoutStatusCacheOnly({
|
||||
serverId: agent.serverId,
|
||||
cwd: agent.cwd,
|
||||
});
|
||||
const checkout = checkoutQuery.data ?? null;
|
||||
const projectPath = showCheckoutInfo
|
||||
? deriveProjectPath(agent.cwd, checkout)
|
||||
: agent.cwd;
|
||||
const branchLabel = showCheckoutInfo ? deriveBranchLabel(checkout) : null;
|
||||
const statusLabel = formatStatusLabel(agent.status);
|
||||
const projectPath = shortenPath(agent.cwd);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.agentItem,
|
||||
isSelected && styles.agentItemSelected,
|
||||
hovered && styles.agentItemHovered,
|
||||
pressed && styles.agentItemPressed,
|
||||
styles.row,
|
||||
isSelected && styles.rowSelected,
|
||||
hovered && styles.rowHovered,
|
||||
pressed && styles.rowPressed,
|
||||
]}
|
||||
onPress={() => onPress(agent)}
|
||||
onLongPress={() => onLongPress(agent)}
|
||||
testID={`agent-row-${agent.serverId}-${agent.id}`}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<View style={styles.agentContent}>
|
||||
<View style={styles.row}>
|
||||
<AgentStatusDot status={agent.status} requiresAttention={agent.requiresAttention} />
|
||||
<Text
|
||||
style={[
|
||||
styles.agentTitle,
|
||||
(isSelected || hovered) && styles.agentTitleHighlighted,
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{agent.title || "New agent"}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.rowInner}>
|
||||
{columns.map((column) => {
|
||||
if (column.key === "session") {
|
||||
return (
|
||||
<SessionCell key={column.key} flex={column.flex} align={column.align}>
|
||||
<View style={styles.primaryCell}>
|
||||
<View style={styles.sessionTitleRow}>
|
||||
<Text
|
||||
style={[
|
||||
styles.sessionTitle,
|
||||
(isSelected || hovered) && styles.sessionTitleHighlighted,
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{agent.title || "New session"}
|
||||
</Text>
|
||||
{agent.archivedAt ? <SessionBadge label="Archived" /> : null}
|
||||
{(agent.pendingPermissionCount ?? 0) > 0 ? (
|
||||
<SessionBadge
|
||||
label={`${agent.pendingPermissionCount} pending`}
|
||||
tone="warning"
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
{isMobile ? (
|
||||
<View style={styles.sessionMetaRow}>
|
||||
<Text style={styles.sessionMetaText} numberOfLines={1}>
|
||||
{projectPath}
|
||||
</Text>
|
||||
<Text style={styles.sessionMetaSeparator}>·</Text>
|
||||
<Text style={styles.sessionMetaText}>{statusLabel}</Text>
|
||||
{agent.serverLabel ? (
|
||||
<>
|
||||
<Text style={styles.sessionMetaSeparator}>·</Text>
|
||||
<Text style={styles.sessionMetaText} numberOfLines={1}>
|
||||
{agent.serverLabel}
|
||||
</Text>
|
||||
</>
|
||||
) : null}
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.secondaryBadgeRow}>
|
||||
{agent.requiresAttention ? (
|
||||
<SessionBadge label="Attention" tone="danger" />
|
||||
) : null}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</SessionCell>
|
||||
);
|
||||
}
|
||||
|
||||
<Text style={styles.secondaryRow} numberOfLines={1}>
|
||||
{shortenPath(projectPath)}
|
||||
{branchLabel ? ` · ${branchLabel}` : ""}
|
||||
{archivedLabel ? ` · ${archivedLabel}` : ""} · {timeAgo}
|
||||
</Text>
|
||||
if (column.key === "project") {
|
||||
return (
|
||||
<SessionCell key={column.key} flex={column.flex} align={column.align}>
|
||||
<View style={styles.projectCell}>
|
||||
<Text style={styles.projectPath} numberOfLines={1}>
|
||||
{projectPath}
|
||||
</Text>
|
||||
<Text style={styles.projectProvider} numberOfLines={1}>
|
||||
{agent.provider}
|
||||
</Text>
|
||||
</View>
|
||||
</SessionCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (column.key === "host") {
|
||||
return (
|
||||
<SessionCell key={column.key} flex={column.flex} align={column.align}>
|
||||
<Text style={styles.hostText} numberOfLines={1}>
|
||||
{agent.serverLabel}
|
||||
</Text>
|
||||
</SessionCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (column.key === "status") {
|
||||
return (
|
||||
<SessionCell key={column.key} flex={column.flex} align={column.align}>
|
||||
<View style={styles.statusCell}>
|
||||
<AgentStatusDot
|
||||
status={agent.status}
|
||||
requiresAttention={agent.requiresAttention}
|
||||
/>
|
||||
<Text style={styles.statusText} numberOfLines={1}>
|
||||
{statusLabel}
|
||||
</Text>
|
||||
</View>
|
||||
</SessionCell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SessionCell key={column.key} flex={column.flex} align={column.align}>
|
||||
<Text style={styles.updatedText} numberOfLines={1}>
|
||||
{timeAgo}
|
||||
</Text>
|
||||
</SessionCell>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionTableSection({
|
||||
section,
|
||||
columns,
|
||||
isMobile,
|
||||
selectedAgentId,
|
||||
onAgentPress,
|
||||
onAgentLongPress,
|
||||
}: {
|
||||
section: AgentListSection;
|
||||
columns: SessionColumnDefinition[];
|
||||
isMobile: boolean;
|
||||
selectedAgentId?: string;
|
||||
onAgentPress: (agent: AggregatedAgent) => void;
|
||||
onAgentLongPress: (agent: AggregatedAgent) => void;
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.sectionBlock}>
|
||||
<View style={styles.sectionHeading}>
|
||||
<Text style={styles.sectionTitle}>{section.title}</Text>
|
||||
<View style={styles.sectionLine} />
|
||||
</View>
|
||||
|
||||
<View style={styles.tableCard}>
|
||||
<View style={styles.tableHeader}>
|
||||
{columns.map((column) => (
|
||||
<SessionCell key={column.key} flex={column.flex} align={column.align}>
|
||||
<Text
|
||||
style={[
|
||||
styles.columnLabel,
|
||||
column.align === "right" && styles.columnLabelRight,
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{column.label}
|
||||
</Text>
|
||||
</SessionCell>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{section.data.map((agent, index) => (
|
||||
<View
|
||||
key={`${agent.serverId}:${agent.id}`}
|
||||
style={index > 0 ? styles.rowDivider : undefined}
|
||||
>
|
||||
<SessionTableRow
|
||||
agent={agent}
|
||||
columns={columns}
|
||||
isMobile={isMobile}
|
||||
selectedAgentId={selectedAgentId}
|
||||
onPress={onAgentPress}
|
||||
onLongPress={onAgentLongPress}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentList({
|
||||
agents,
|
||||
showCheckoutInfo = true,
|
||||
isRefreshing = false,
|
||||
onRefresh,
|
||||
selectedAgentId,
|
||||
@@ -158,9 +377,10 @@ export function AgentList({
|
||||
}: AgentListProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const pathname = usePathname();
|
||||
const queryClient = useQueryClient();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null);
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
|
||||
const actionClient = useSessionStore((state) =>
|
||||
actionAgent?.serverId ? state.sessions[actionAgent.serverId]?.client ?? null : null
|
||||
@@ -168,6 +388,14 @@ export function AgentList({
|
||||
|
||||
const isActionSheetVisible = actionAgent !== null;
|
||||
const isActionDaemonUnavailable = Boolean(actionAgent?.serverId && !actionClient);
|
||||
const showHostColumn = useMemo(
|
||||
() => new Set(agents.map((agent) => agent.serverId)).size > 1,
|
||||
[agents]
|
||||
);
|
||||
const columns = useMemo(
|
||||
() => getVisibleColumns({ isMobile, showHostColumn }),
|
||||
[isMobile, showHostColumn]
|
||||
);
|
||||
|
||||
const handleAgentPress = useCallback(
|
||||
(agent: AggregatedAgent) => {
|
||||
@@ -215,51 +443,6 @@ export function AgentList({
|
||||
setActionAgent(null);
|
||||
}, [actionAgent, actionClient]);
|
||||
|
||||
const viewabilityConfig = useMemo(
|
||||
() => ({ itemVisiblePercentThreshold: 30 }),
|
||||
[]
|
||||
);
|
||||
|
||||
const onViewableItemsChanged = useCallback(
|
||||
({ viewableItems }: { viewableItems: Array<ViewToken> }) => {
|
||||
if (!showCheckoutInfo) {
|
||||
return;
|
||||
}
|
||||
for (const token of viewableItems) {
|
||||
const agent = token.item as AggregatedAgent | undefined;
|
||||
if (!agent) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const runtime = getHostRuntimeStore();
|
||||
const client = runtime.getClient(agent.serverId);
|
||||
const isConnected = isHostRuntimeConnected(runtime.getSnapshot(agent.serverId));
|
||||
if (!client || !isConnected) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const queryKey = checkoutStatusQueryKey(agent.serverId, agent.cwd);
|
||||
const queryState = queryClient.getQueryState(queryKey);
|
||||
const isFetching = queryState?.fetchStatus === "fetching";
|
||||
const isFresh =
|
||||
typeof queryState?.dataUpdatedAt === "number" &&
|
||||
Date.now() - queryState.dataUpdatedAt < CHECKOUT_STATUS_STALE_TIME;
|
||||
if (isFetching || isFresh) {
|
||||
continue;
|
||||
}
|
||||
|
||||
void queryClient.prefetchQuery({
|
||||
queryKey,
|
||||
queryFn: async () => await client.getCheckoutStatus(agent.cwd),
|
||||
staleTime: CHECKOUT_STATUS_STALE_TIME,
|
||||
}).catch((error) => {
|
||||
console.warn("[checkout_status] prefetch failed", error);
|
||||
});
|
||||
}
|
||||
},
|
||||
[queryClient, showCheckoutInfo]
|
||||
);
|
||||
|
||||
const sections = useMemo((): AgentListSection[] => {
|
||||
const order = ["Today", "Yesterday", "This week", "This month", "Older"] as const;
|
||||
const buckets = new Map<string, AggregatedAgent[]>();
|
||||
@@ -281,55 +464,33 @@ export function AgentList({
|
||||
return result;
|
||||
}, [agents]);
|
||||
|
||||
const renderAgentItem: SectionListRenderItem<AggregatedAgent, AgentListSection> =
|
||||
useCallback(
|
||||
({ item: agent }) => (
|
||||
<AgentListRow
|
||||
agent={agent}
|
||||
selectedAgentId={selectedAgentId}
|
||||
showCheckoutInfo={showCheckoutInfo}
|
||||
onPress={handleAgentPress}
|
||||
onLongPress={handleAgentLongPress}
|
||||
/>
|
||||
),
|
||||
[handleAgentLongPress, handleAgentPress, selectedAgentId, showCheckoutInfo]
|
||||
);
|
||||
|
||||
const renderSectionHeader = useCallback(
|
||||
({ section }: { section: AgentListSection }) => (
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={styles.sectionTitle}>{section.title}</Text>
|
||||
</View>
|
||||
const renderSection: ListRenderItem<AgentListSection> = useCallback(
|
||||
({ item: section }) => (
|
||||
<SessionTableSection
|
||||
section={section}
|
||||
columns={columns}
|
||||
isMobile={isMobile}
|
||||
selectedAgentId={selectedAgentId}
|
||||
onAgentPress={handleAgentPress}
|
||||
onAgentLongPress={handleAgentLongPress}
|
||||
/>
|
||||
),
|
||||
[]
|
||||
[columns, handleAgentLongPress, handleAgentPress, isMobile, selectedAgentId]
|
||||
);
|
||||
|
||||
const keyExtractor = useCallback(
|
||||
(agent: AggregatedAgent) => `${agent.serverId}:${agent.id}`,
|
||||
[]
|
||||
);
|
||||
const keyExtractor = useCallback((section: AgentListSection) => section.key, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionList
|
||||
sections={sections}
|
||||
<FlatList
|
||||
data={sections}
|
||||
style={styles.list}
|
||||
contentContainerStyle={styles.listContent}
|
||||
keyExtractor={keyExtractor}
|
||||
renderItem={renderAgentItem}
|
||||
renderSectionHeader={renderSectionHeader}
|
||||
stickySectionHeadersEnabled={false}
|
||||
extraData={selectedAgentId}
|
||||
renderItem={renderSection}
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
initialNumToRender={12}
|
||||
windowSize={7}
|
||||
maxToRenderPerBatch={12}
|
||||
updateCellsBatchingPeriod={16}
|
||||
removeClippedSubviews={true}
|
||||
ListFooterComponent={listFooterComponent}
|
||||
onViewableItemsChanged={onViewableItemsChanged}
|
||||
viewabilityConfig={viewabilityConfig}
|
||||
refreshControl={
|
||||
onRefresh ? (
|
||||
<RefreshControl
|
||||
@@ -353,12 +514,15 @@ export function AgentList({
|
||||
style={styles.sheetBackdrop}
|
||||
onPress={handleCloseActionSheet}
|
||||
/>
|
||||
<View style={[styles.sheetContainer, { paddingBottom: Math.max(insets.bottom, theme.spacing[6]) }]}>
|
||||
<View
|
||||
style={[
|
||||
styles.sheetContainer,
|
||||
{ paddingBottom: Math.max(insets.bottom, theme.spacing[6]) },
|
||||
]}
|
||||
>
|
||||
<View style={styles.sheetHandle} />
|
||||
<Text style={styles.sheetTitle}>
|
||||
{isActionDaemonUnavailable
|
||||
? "Host offline"
|
||||
: "Archive this agent?"}
|
||||
{isActionDaemonUnavailable ? "Host offline" : "Archive this session?"}
|
||||
</Text>
|
||||
<View style={styles.sheetButtonRow}>
|
||||
<Pressable
|
||||
@@ -397,60 +561,198 @@ const styles = StyleSheet.create((theme) => ({
|
||||
minHeight: 0,
|
||||
},
|
||||
listContent: {
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingHorizontal: {
|
||||
xs: theme.spacing[3],
|
||||
md: theme.spacing[6],
|
||||
},
|
||||
paddingTop: theme.spacing[2],
|
||||
paddingBottom: theme.spacing[4],
|
||||
paddingBottom: theme.spacing[6],
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
sectionHeader: {
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
sectionBlock: {
|
||||
marginTop: theme.spacing[2],
|
||||
},
|
||||
sectionHeading: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
marginBottom: theme.spacing[2],
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: "500",
|
||||
fontWeight: "600",
|
||||
color: theme.colors.foregroundMuted,
|
||||
textAlign: "left",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0.6,
|
||||
},
|
||||
agentItem: {
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
marginBottom: theme.spacing[1],
|
||||
},
|
||||
agentItemSelected: {
|
||||
sectionLine: {
|
||||
flex: 1,
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
agentItemHovered: {
|
||||
tableCard: {
|
||||
overflow: "hidden",
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: theme.colors.surface2,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
agentItemPressed: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
tableHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: {
|
||||
xs: theme.spacing[3],
|
||||
md: theme.spacing[4],
|
||||
},
|
||||
paddingVertical: theme.spacing[2],
|
||||
backgroundColor: theme.colors.surface0,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: theme.colors.surface2,
|
||||
},
|
||||
agentContent: {
|
||||
flex: 1,
|
||||
gap: theme.spacing[0],
|
||||
columnLabel: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: "600",
|
||||
color: theme.colors.foregroundMuted,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0.6,
|
||||
},
|
||||
columnLabelRight: {
|
||||
textAlign: "right",
|
||||
},
|
||||
rowDivider: {
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: theme.colors.surface2,
|
||||
},
|
||||
row: {
|
||||
paddingHorizontal: {
|
||||
xs: theme.spacing[3],
|
||||
md: theme.spacing[4],
|
||||
},
|
||||
paddingVertical: {
|
||||
xs: theme.spacing[2],
|
||||
md: theme.spacing[3],
|
||||
},
|
||||
},
|
||||
rowInner: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
rowSelected: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
rowHovered: {
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
rowPressed: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
cell: {
|
||||
minWidth: 0,
|
||||
},
|
||||
cellLeft: {
|
||||
alignItems: "flex-start",
|
||||
},
|
||||
cellRight: {
|
||||
alignItems: "flex-end",
|
||||
},
|
||||
primaryCell: {
|
||||
width: "100%",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
sessionTitleRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
flexWrap: "wrap",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
sessionTitle: {
|
||||
flexShrink: 1,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: "500",
|
||||
color: theme.colors.foreground,
|
||||
opacity: 0.86,
|
||||
},
|
||||
sessionTitleHighlighted: {
|
||||
opacity: 1,
|
||||
},
|
||||
sessionMetaRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
flexWrap: "wrap",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
sessionMetaText: {
|
||||
maxWidth: "100%",
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
sessionMetaSeparator: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
opacity: 0.7,
|
||||
},
|
||||
secondaryBadgeRow: {
|
||||
minHeight: theme.spacing[6],
|
||||
justifyContent: "center",
|
||||
},
|
||||
projectCell: {
|
||||
width: "100%",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
projectPath: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
projectProvider: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0.6,
|
||||
},
|
||||
hostText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
statusCell: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
agentTitle: {
|
||||
flex: 1,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: "400",
|
||||
statusText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foreground,
|
||||
opacity: 0.8,
|
||||
},
|
||||
agentTitleHighlighted: {
|
||||
color: theme.colors.foreground,
|
||||
opacity: 1,
|
||||
},
|
||||
secondaryRow: {
|
||||
updatedText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: "300",
|
||||
color: theme.colors.foregroundMuted,
|
||||
textAlign: "right",
|
||||
},
|
||||
badge: {
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1],
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
badgeWarning: {
|
||||
backgroundColor: "rgba(245, 158, 11, 0.12)",
|
||||
},
|
||||
badgeDanger: {
|
||||
backgroundColor: "rgba(239, 68, 68, 0.14)",
|
||||
},
|
||||
badgeText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: "600",
|
||||
color: theme.colors.foregroundMuted,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0.4,
|
||||
},
|
||||
badgeTextWarning: {
|
||||
color: theme.colors.palette.amber[500],
|
||||
},
|
||||
badgeTextDanger: {
|
||||
color: theme.colors.palette.red[300],
|
||||
},
|
||||
sheetOverlay: {
|
||||
flex: 1,
|
||||
|
||||
@@ -41,10 +41,12 @@ function FilePreviewBody({
|
||||
preview,
|
||||
isLoading,
|
||||
showDesktopWebScrollbar,
|
||||
isMobile,
|
||||
}: {
|
||||
preview: ExplorerFile | null;
|
||||
isLoading: boolean;
|
||||
showDesktopWebScrollbar: boolean;
|
||||
isMobile: boolean;
|
||||
}) {
|
||||
const enablePreviewDesktopScrollbar = showDesktopWebScrollbar;
|
||||
const previewScrollRef = useRef<RNScrollView>(null);
|
||||
@@ -101,14 +103,20 @@ function FilePreviewBody({
|
||||
scrollEventThrottle={enablePreviewDesktopScrollbar ? 16 : undefined}
|
||||
showsVerticalScrollIndicator={!enablePreviewDesktopScrollbar}
|
||||
>
|
||||
<RNScrollView
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator
|
||||
contentContainerStyle={styles.previewCodeScrollContent}
|
||||
>
|
||||
<Text style={styles.codeText}>{preview.content}</Text>
|
||||
</RNScrollView>
|
||||
{isMobile ? (
|
||||
<View style={styles.previewCodeScrollContent}>
|
||||
<Text style={styles.codeText}>{preview.content}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<RNScrollView
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator
|
||||
contentContainerStyle={styles.previewCodeScrollContent}
|
||||
>
|
||||
<Text style={styles.codeText}>{preview.content}</Text>
|
||||
</RNScrollView>
|
||||
)}
|
||||
</RNScrollView>
|
||||
<WebDesktopScrollbarOverlay
|
||||
enabled={enablePreviewDesktopScrollbar}
|
||||
@@ -211,6 +219,7 @@ export function FilePane({
|
||||
preview={query.data?.file ?? null}
|
||||
isLoading={query.isFetching}
|
||||
showDesktopWebScrollbar={showDesktopWebScrollbar}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -10,7 +10,7 @@ import Animated, {
|
||||
} from 'react-native-reanimated'
|
||||
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
|
||||
import { Plus, Settings, Users } from 'lucide-react-native'
|
||||
import { MessagesSquare, Plus, Settings } from 'lucide-react-native'
|
||||
import { router, usePathname } from 'expo-router'
|
||||
import { usePanelStore } from '@/stores/panel-store'
|
||||
import { SidebarWorkspaceList } from './sidebar-workspace-list'
|
||||
@@ -389,12 +389,12 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
||||
nativeID="sidebar-all-agents"
|
||||
collapsable={false}
|
||||
accessible
|
||||
accessibilityLabel="All agents"
|
||||
accessibilityLabel="Sessions"
|
||||
accessibilityRole="button"
|
||||
onPress={handleViewMore}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<Users
|
||||
<MessagesSquare
|
||||
size={theme.iconSize.lg}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
@@ -509,12 +509,12 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
||||
nativeID="sidebar-all-agents"
|
||||
collapsable={false}
|
||||
accessible
|
||||
accessibilityLabel="All agents"
|
||||
accessibilityLabel="Sessions"
|
||||
accessibilityRole="button"
|
||||
onPress={handleViewMore}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<Users
|
||||
<MessagesSquare
|
||||
size={theme.iconSize.lg}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
|
||||
@@ -719,6 +719,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
onChangeText={handleInputChange}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={theme.colors.surface4}
|
||||
accessibilityLabel="Message agent..."
|
||||
onFocus={() => {
|
||||
isInputFocusedRef.current = true
|
||||
onFocusChange?.(true)
|
||||
|
||||
@@ -147,6 +147,11 @@ function WorkspaceStatusIndicator({
|
||||
const { theme } = useUnistyles()
|
||||
const color = resolveStatusDotColor({ theme, bucket })
|
||||
const shouldShowSyncedLoader = shouldRenderSyncedStatusLoader({ bucket })
|
||||
const shouldRenderIdlePlaceholder = !loading && !shouldShowSyncedLoader && bucket === 'done'
|
||||
|
||||
if (shouldRenderIdlePlaceholder) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.workspaceStatusDot}>
|
||||
@@ -1548,7 +1553,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
marginBottom: theme.spacing[1],
|
||||
},
|
||||
workspaceListContainer: {
|
||||
marginLeft: 0,
|
||||
marginLeft: theme.spacing[3],
|
||||
paddingLeft: theme.spacing[2],
|
||||
borderLeftWidth: StyleSheet.hairlineWidth,
|
||||
borderLeftColor: theme.colors.surface1,
|
||||
},
|
||||
emptyText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
@@ -1668,7 +1676,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
workspaceRowLeft: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing[2],
|
||||
gap: theme.spacing[1],
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
@@ -1703,11 +1711,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
position: 'relative',
|
||||
},
|
||||
workspaceStatusDot: {
|
||||
width: 16,
|
||||
width: 11,
|
||||
height: 16,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
flexShrink: 0,
|
||||
alignItems: 'center',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
workspaceStatusDotFill: {
|
||||
|
||||
Reference in New Issue
Block a user