chore: delete unused files flagged by knip

38 files removed across app and server (verified unreferenced via
grep + typecheck). Includes dead components, hooks, stores, utils,
POC scripts, an empty orchestrator stub, stale playwright configs,
and a duplicate workspace-registry test-helpers module.

knip config tightened: added babel.config.js, native platform
extensions, and test-stubs/ to app entry patterns.
This commit is contained in:
Mohamed Boudra
2026-04-23 22:29:29 +07:00
parent dc92863c35
commit 2dd8c27573
38 changed files with 4 additions and 8320 deletions

View File

@@ -22,12 +22,15 @@
"entry": [
"index.ts",
"app.config.js",
"babel.config.js",
"app/**/*.{ts,tsx}",
"src/**/*.test.{ts,tsx}",
"src/**/*.e2e.{ts,tsx}",
"src/**/*.native.{ts,tsx}",
"e2e/**/*.{ts,tsx}",
"playwright.config.{ts,js}",
"vitest.config.{ts,js}"
"vitest.config.{ts,js}",
"test-stubs/**/*.ts"
],
"project": ["**/*.{ts,tsx,js,jsx}"],
"ignore": ["android/**", "ios/**", ".expo/**", "dist/**", "scripts/reset-project.js"]

View File

@@ -1,29 +0,0 @@
import { defineConfig, devices } from "@playwright/test";
const baseURL =
process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`;
export default defineConfig({
testDir: "./e2e",
globalSetup: "./e2e/global-setup.ts",
timeout: 60_000,
expect: {
timeout: 10_000,
},
fullyParallel: false,
workers: 1,
retries: process.env.CI ? 1 : 0,
reporter: [["list"]],
use: {
baseURL,
trace: "retain-on-failure",
screenshot: "only-on-failure",
video: "retain-on-failure",
},
projects: [
{
name: "Desktop Firefox",
use: { ...devices["Desktop Firefox"] },
},
],
});

View File

@@ -1,29 +0,0 @@
import { defineConfig, devices } from "@playwright/test";
const baseURL =
process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`;
export default defineConfig({
testDir: "./e2e",
globalSetup: "./e2e/global-setup.ts",
timeout: 60_000,
expect: {
timeout: 10_000,
},
fullyParallel: false,
workers: 1,
retries: 0,
reporter: [["list"]],
use: {
baseURL,
trace: "retain-on-failure",
screenshot: "only-on-failure",
video: "retain-on-failure",
},
projects: [
{
name: "Desktop Safari",
use: { ...devices["Desktop Safari"] },
},
],
});

View File

@@ -1,202 +0,0 @@
import { View, Text, Pressable, ScrollView } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import type { Agent } from "@/contexts/session-context";
export interface ActiveProcessesProps {
agents: Agent[];
viewMode: "orchestrator" | "agent";
activeAgentId: string | null;
onSelectAgent: (serverId: string, id: string) => void;
onSelectOrchestrator: () => void;
}
function getAgentStatusColor(status: Agent["status"]): string {
switch (status) {
case "initializing":
return "#f59e0b";
case "idle":
return "#22c55e";
case "running":
return "#3b82f6";
case "error":
return "#ef4444";
case "closed":
return "#9ca3af";
default:
return "#9ca3af";
}
}
function getModeName(modeId?: string, availableModes?: Agent["availableModes"]): string {
if (!modeId) return "unknown";
const mode = availableModes?.find((m) => m.id === modeId);
return mode?.label || modeId;
}
function getModeColor(modeId?: string): string {
if (!modeId) return "#9ca3af"; // gray
if (modeId.includes("bypass") || modeId.includes("full-access")) return "#ef4444"; // red - dangerous
if (modeId.includes("auto") || modeId.includes("build") || modeId.includes("acceptEdits"))
return "#3b82f6"; // blue - build/auto
if (modeId.includes("plan") || modeId.includes("architect")) return "#a855f7"; // purple - planning
if (modeId.includes("ask") || modeId.includes("read-only") || modeId === "default")
return "#22c55e"; // green - safest
return "#9ca3af"; // gray - unknown
}
const styles = StyleSheet.create((theme) => ({
container: {
backgroundColor: theme.colors.surface2,
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
header: {
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
backButton: {
backgroundColor: theme.colors.surface2,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
},
backButtonActive: {
backgroundColor: theme.colors.palette.zinc[700],
},
backButtonText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: "600",
textAlign: "center",
},
scrollView: {
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
},
processItem: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
},
processItemActive: {
backgroundColor: theme.colors.primary,
},
processItemInactive: {
backgroundColor: theme.colors.surface2,
},
agentIcon: {
width: 12,
height: 12,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.palette.blue[500],
},
commandIcon: {
width: 12,
height: 12,
borderRadius: theme.borderRadius.sm,
backgroundColor: theme.colors.palette.purple[500],
},
processText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.xs,
fontWeight: "500",
},
processTextActive: {
color: theme.colors.primaryForeground,
},
statusDot: {
width: 8,
height: 8,
borderRadius: theme.borderRadius.full,
},
modeIndicator: {
width: 6,
height: 6,
borderRadius: theme.borderRadius.full,
opacity: 0.3,
},
}));
export function ActiveProcesses({
agents,
viewMode,
activeAgentId,
onSelectAgent,
onSelectOrchestrator,
}: ActiveProcessesProps) {
// Only show if there's at least one agent
if (agents.length === 0) {
return null;
}
return (
<View style={styles.container}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.scrollView}
contentContainerStyle={{ gap: 8 }}
>
{/* Orchestrator pill */}
<Pressable
onPress={onSelectOrchestrator}
style={({ pressed }) => [
styles.processItem,
viewMode === "orchestrator" ? styles.processItemActive : styles.processItemInactive,
pressed && { opacity: 0.7 },
]}
>
<View style={styles.agentIcon} />
<Text
style={[styles.processText, viewMode === "orchestrator" && styles.processTextActive]}
>
Orchestrator
</Text>
</Pressable>
{/* Agent pills */}
{agents.map((agent) => {
const isActive = viewMode === "agent" && activeAgentId === agent.id;
return (
<Pressable
key={`agent-${agent.id}`}
onPress={() => onSelectAgent(agent.serverId, agent.id)}
style={({ pressed }) => [
styles.processItem,
isActive ? styles.processItemActive : styles.processItemInactive,
pressed && { opacity: 0.7 },
]}
>
<View style={styles.agentIcon} />
<Text style={[styles.processText, isActive && styles.processTextActive]}>
{agent.id.substring(0, 8)}
</Text>
<View
style={[styles.statusDot, { backgroundColor: getAgentStatusColor(agent.status) }]}
/>
{agent.currentModeId && (
<View
style={[
styles.modeIndicator,
{ backgroundColor: getModeColor(agent.currentModeId) },
]}
/>
)}
</Pressable>
);
})}
</ScrollView>
</View>
);
}

View File

@@ -1,403 +0,0 @@
import { useState } from "react";
import { View, Text, Pressable } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { Fonts } from "@/constants/theme";
import type {
AgentActivity,
GroupedTextMessage,
MergedToolCall,
SessionUpdate,
} from "@/types/agent-activity";
interface AgentActivityItemProps {
item: GroupedTextMessage | MergedToolCall | AgentActivity;
}
function formatTimestamp(date: Date): string {
return new Intl.DateTimeFormat("en-US", {
hour: "numeric",
minute: "2-digit",
second: "2-digit",
hour12: true,
}).format(date);
}
function getToolIcon(toolKind?: string): string {
switch (toolKind) {
case "read":
return "📖";
case "edit":
return "✏️";
case "delete":
return "🗑️";
case "move":
return "📦";
case "search":
return "🔍";
case "execute":
return "▶️";
case "think":
return "💭";
case "fetch":
return "🌐";
case "switch_mode":
return "🔄";
default:
return "🔧";
}
}
function getStatusColor(status?: string): string {
switch (status) {
case "pending":
return "#9ca3af";
case "in_progress":
return "#fbbf24";
case "completed":
return "#22c55e";
case "failed":
return "#ef4444";
default:
return "#6b7280";
}
}
function GroupedTextItem({ item }: { item: GroupedTextMessage }) {
const isThought = item.messageType === "thought";
return (
<View style={[stylesheet.card, isThought && stylesheet.thoughtCard]}>
<Text style={[stylesheet.timestamp, isThought && stylesheet.thoughtTimestamp]}>
{formatTimestamp(item.startTimestamp)}
</Text>
{isThought && <Text style={stylesheet.thoughtLabel}>💭 Thinking</Text>}
<Text style={[stylesheet.text, isThought && stylesheet.thoughtText]}>{item.text}</Text>
</View>
);
}
function MergedToolCallItem({ item }: { item: MergedToolCall }) {
const [isExpanded, setIsExpanded] = useState(false);
return (
<View style={stylesheet.toolCard}>
<Pressable onPress={() => setIsExpanded(!isExpanded)} style={stylesheet.toolHeader}>
<View style={stylesheet.toolHeaderLeft}>
<Text style={stylesheet.timestamp}>{formatTimestamp(item.startTimestamp)}</Text>
<View style={stylesheet.toolTitleRow}>
<Text style={stylesheet.toolIcon}>{getToolIcon(item.toolKind)}</Text>
<Text style={stylesheet.toolTitle}>{item.title}</Text>
<View
style={[stylesheet.statusBadge, { backgroundColor: getStatusColor(item.status) }]}
>
<Text style={stylesheet.statusText}>{item.status}</Text>
</View>
</View>
</View>
<Text style={stylesheet.expandIcon}>{isExpanded ? "▼" : "▶"}</Text>
</Pressable>
{isExpanded && (
<View style={stylesheet.toolContent}>
{item.input && (
<View style={stylesheet.section}>
<Text style={stylesheet.sectionTitle}>Input:</Text>
<Text style={stylesheet.code}>{JSON.stringify(item.input, null, 2)}</Text>
</View>
)}
{item.output && (
<View style={stylesheet.section}>
<Text style={stylesheet.sectionTitle}>Output:</Text>
<Text style={stylesheet.code}>{JSON.stringify(item.output, null, 2)}</Text>
</View>
)}
{!item.input && !item.output && (
<Text style={stylesheet.emptyText}>No details available</Text>
)}
</View>
)}
</View>
);
}
function PlanItem({ update, timestamp }: { update: SessionUpdate; timestamp: Date }) {
const [isExpanded, setIsExpanded] = useState(true);
if (update.kind !== "plan") {
return null;
}
return (
<View style={stylesheet.planCard}>
<Pressable onPress={() => setIsExpanded(!isExpanded)} style={stylesheet.planHeader}>
<View style={stylesheet.planHeaderLeft}>
<Text style={stylesheet.timestamp}>{formatTimestamp(timestamp)}</Text>
<Text style={stylesheet.planTitle}>📋 Tasks ({update.entries.length})</Text>
</View>
<Text style={stylesheet.expandIcon}>{isExpanded ? "▼" : "▶"}</Text>
</Pressable>
{isExpanded && (
<View style={stylesheet.planContent}>
{update.entries.map((entry, idx) => (
<View key={idx} style={stylesheet.planEntry}>
<Text style={[stylesheet.planEntryStatus, { color: getStatusColor(entry.status) }]}>
{entry.status === "completed" ? "✓" : entry.status === "in_progress" ? "⏳" : "○"}
</Text>
<Text style={stylesheet.planEntryText}>{entry.content}</Text>
</View>
))}
</View>
)}
</View>
);
}
function UnknownActivityItem({ update, timestamp }: { update: SessionUpdate; timestamp: Date }) {
const [showDrawer, setShowDrawer] = useState(false);
return (
<View style={stylesheet.unknownCard}>
<Pressable onPress={() => setShowDrawer(!showDrawer)} style={stylesheet.unknownHeader}>
<Text style={stylesheet.timestamp}>{formatTimestamp(timestamp)}</Text>
<View style={stylesheet.unknownBadge}>
<Text style={stylesheet.unknownBadgeText}>{update.kind}</Text>
</View>
</Pressable>
{showDrawer && (
<View style={stylesheet.drawerContent}>
<Text style={stylesheet.code}>{JSON.stringify(update, null, 2)}</Text>
</View>
)}
</View>
);
}
export function AgentActivityItem({ item }: AgentActivityItemProps) {
// Grouped text message
if ("kind" in item && item.kind === "grouped_text") {
return <GroupedTextItem item={item} />;
}
// Merged tool call
if ("kind" in item && item.kind === "merged_tool_call") {
return <MergedToolCallItem item={item} />;
}
// Individual activity
const activity = item as AgentActivity;
const update = activity.update;
// Tasks
if (update.kind === "plan") {
return <PlanItem update={update} timestamp={activity.timestamp} />;
}
// Available commands update
if (update.kind === "available_commands_update") {
return (
<View style={stylesheet.card}>
<Text style={stylesheet.timestamp}>{formatTimestamp(activity.timestamp)}</Text>
<Text style={stylesheet.infoText}>
Commands updated ({update.availableCommands.length} available)
</Text>
</View>
);
}
// Current mode update
if (update.kind === "current_mode_update") {
return (
<View style={stylesheet.card}>
<Text style={stylesheet.timestamp}>{formatTimestamp(activity.timestamp)}</Text>
<Text style={stylesheet.infoText}>Mode changed to: {update.currentModeId}</Text>
</View>
);
}
// Unknown activity type
return <UnknownActivityItem update={update} timestamp={activity.timestamp} />;
}
const stylesheet = StyleSheet.create((theme) => ({
card: {
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
padding: theme.spacing[3],
marginBottom: theme.spacing[2],
},
thoughtCard: {
backgroundColor: theme.colors.surface2,
borderLeftWidth: 3,
borderLeftColor: theme.colors.palette.purple[500],
paddingVertical: theme.spacing[2],
},
timestamp: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
marginBottom: theme.spacing[1],
},
thoughtTimestamp: {
marginBottom: theme.spacing[0],
},
thoughtLabel: {
color: theme.colors.palette.purple[500],
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
marginBottom: theme.spacing[0],
},
text: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
lineHeight: 20,
},
thoughtText: {
color: theme.colors.foregroundMuted,
fontStyle: "italic",
},
toolCard: {
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
marginBottom: theme.spacing[2],
overflow: "hidden",
},
toolHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
padding: theme.spacing[3],
},
toolHeaderLeft: {
flex: 1,
},
toolTitleRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
marginTop: theme.spacing[1],
},
toolIcon: {
fontSize: theme.fontSize.base,
},
toolTitle: {
color: theme.colors.palette.blue[400],
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
flex: 1,
},
statusBadge: {
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
borderRadius: theme.borderRadius.md,
},
statusText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
},
expandIcon: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
toolContent: {
borderTopWidth: theme.borderWidth[1],
borderTopColor: theme.colors.border,
padding: theme.spacing[3],
},
section: {
marginBottom: theme.spacing[3],
},
sectionTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
marginBottom: theme.spacing[1],
},
code: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontFamily: Fonts.mono,
backgroundColor: theme.colors.surface2,
padding: theme.spacing[2],
borderRadius: theme.borderRadius.md,
},
emptyText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontStyle: "italic",
},
planCard: {
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
marginBottom: theme.spacing[2],
overflow: "hidden",
},
planHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
padding: theme.spacing[3],
},
planHeaderLeft: {
flex: 1,
},
planTitle: {
color: theme.colors.palette.green[400],
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
marginTop: theme.spacing[1],
},
planContent: {
borderTopWidth: theme.borderWidth[1],
borderTopColor: theme.colors.border,
padding: theme.spacing[3],
},
planEntry: {
flexDirection: "row",
alignItems: "flex-start",
gap: theme.spacing[2],
marginBottom: theme.spacing[2],
},
planEntryStatus: {
fontSize: theme.fontSize.sm,
marginTop: 2,
},
planEntryText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
flex: 1,
},
infoText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
unknownCard: {
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
marginBottom: theme.spacing[2],
overflow: "hidden",
},
unknownHeader: {
padding: theme.spacing[3],
},
unknownBadge: {
backgroundColor: theme.colors.palette.orange[600],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.md,
marginTop: theme.spacing[2],
alignSelf: "flex-start",
},
unknownBadgeText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
},
drawerContent: {
borderTopWidth: theme.borderWidth[1],
borderTopColor: theme.colors.border,
padding: theme.spacing[3],
backgroundColor: theme.colors.surface2,
},
}));

File diff suppressed because it is too large Load Diff

View File

@@ -1,244 +0,0 @@
import { View, Text, ScrollView, Pressable, Modal } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { StyleSheet } from "react-native-unistyles";
import { Fonts } from "@/constants/theme";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
export interface Artifact {
id: string;
type: "markdown" | "diff" | "image" | "code";
title: string;
content: string;
isBase64: boolean;
}
interface ArtifactDrawerProps {
artifact: Artifact | null;
onClose: () => void;
}
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
backgroundColor: theme.colors.surface0,
flexDirection: "column",
},
header: {
paddingBottom: theme.spacing[4],
paddingHorizontal: theme.spacing[4],
borderBottomWidth: theme.borderWidth[1],
borderBottomColor: theme.colors.border,
},
headerRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
titleContainer: {
flex: 1,
marginRight: theme.spacing[4],
},
title: {
color: theme.colors.foreground,
fontSize: theme.fontSize["2xl"],
fontWeight: theme.fontWeight.bold,
},
headerActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
badge: {
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[1],
borderRadius: theme.borderRadius.full,
},
badgeMarkdown: {
backgroundColor: theme.colors.primary,
},
badgeDiff: {
backgroundColor: theme.colors.palette.purple[600],
},
badgeImage: {
backgroundColor: theme.colors.palette.green[600],
},
badgeCode: {
backgroundColor: theme.colors.palette.orange[600],
},
badgeText: {
color: theme.colors.primaryForeground,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
},
closeButton: {
backgroundColor: theme.colors.surface2,
width: 40,
height: 40,
borderRadius: 20,
alignItems: "center",
justifyContent: "center",
},
closeButtonText: {
color: theme.colors.foreground,
fontSize: theme.fontSize["2xl"],
fontWeight: theme.fontWeight.bold,
},
contentScroll: {
flex: 1,
backgroundColor: theme.colors.surface0,
},
contentScrollContainer: {
padding: theme.spacing[4],
flexGrow: 1,
},
imagePlaceholder: {
flex: 1,
alignItems: "center",
justifyContent: "center",
},
imagePlaceholderText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
imagePlaceholderSubtext: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
marginTop: theme.spacing[2],
},
codeContainer: {
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
padding: theme.spacing[4],
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
},
codeText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontFamily: Fonts.mono,
},
metadataContainer: {
backgroundColor: theme.colors.surface2,
padding: theme.spacing[3],
borderTopWidth: theme.borderWidth[1],
borderTopColor: theme.colors.border,
marginTop: theme.spacing[2],
},
metadataTitle: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
marginBottom: theme.spacing[2],
},
metadataRow: {
flexDirection: "row",
marginBottom: theme.spacing[1],
},
metadataLabel: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
width: 80,
},
metadataValue: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
flex: 1,
fontFamily: Fonts.mono,
},
}));
export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) {
const webScrollbarStyle = useWebScrollbarStyle();
if (!artifact) {
return null;
}
// Decode content if base64
const content = artifact.isBase64 ? atob(artifact.content) : artifact.content;
// Type badge style mapping
const typeBadgeStyles = {
markdown: styles.badgeMarkdown,
diff: styles.badgeDiff,
image: styles.badgeImage,
code: styles.badgeCode,
};
return (
<Modal
visible={!!artifact}
animationType="slide"
presentationStyle="pageSheet"
onRequestClose={onClose}
>
<SafeAreaView edges={["top", "bottom"]} style={styles.container}>
{/* Header */}
<View style={styles.header}>
<View style={styles.headerRow}>
<View style={styles.titleContainer}>
<Text style={styles.title} numberOfLines={2}>
{artifact.title}
</Text>
</View>
<View style={styles.headerActions}>
<View style={[styles.badge, typeBadgeStyles[artifact.type]]}>
<Text style={styles.badgeText}>{artifact.type.toUpperCase()}</Text>
</View>
<Pressable onPress={onClose} style={styles.closeButton}>
<Text style={styles.closeButtonText}>×</Text>
</Pressable>
</View>
</View>
</View>
{/* Content */}
<ScrollView
style={[styles.contentScroll, webScrollbarStyle]}
contentContainerStyle={styles.contentScrollContainer}
>
{artifact.type === "image" ? (
<View style={styles.imagePlaceholder}>
<Text style={styles.imagePlaceholderText}>Image viewing not yet implemented</Text>
<Text style={styles.imagePlaceholderSubtext}>Base64 image data received</Text>
</View>
) : (
<View style={styles.codeContainer}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={true}
style={webScrollbarStyle}
>
<Text style={styles.codeText}>{content}</Text>
</ScrollView>
</View>
)}
</ScrollView>
{/* Metadata - Fixed at bottom */}
<View style={styles.metadataContainer}>
<Text style={styles.metadataTitle}>METADATA</Text>
<View>
<View style={styles.metadataRow}>
<Text style={styles.metadataLabel}>ID:</Text>
<Text style={styles.metadataValue}>{artifact.id}</Text>
</View>
<View style={styles.metadataRow}>
<Text style={styles.metadataLabel}>Type:</Text>
<Text style={styles.metadataValue}>{artifact.type}</Text>
</View>
<View style={styles.metadataRow}>
<Text style={styles.metadataLabel}>Encoding:</Text>
<Text style={styles.metadataValue}>
{artifact.isBase64 ? "Base64" : "Plain text"}
</Text>
</View>
<View style={styles.metadataRow}>
<Text style={styles.metadataLabel}>Size:</Text>
<Text style={styles.metadataValue}>{content.length.toLocaleString()} characters</Text>
</View>
</View>
</View>
</SafeAreaView>
</Modal>
);
}

View File

@@ -1,190 +0,0 @@
import { useState, useMemo } from "react";
import { View, Text, Pressable } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import * as Clipboard from "expo-clipboard";
import { Check, Copy, X } from "lucide-react-native";
export interface AudioDebugInfo {
requestId?: string | null;
transcript?: string;
debugRecordingPath?: string;
format?: string;
byteLength?: number;
duration?: number;
avgLogprob?: number;
isLowConfidence?: boolean;
}
interface AudioDebugNoticeProps {
info: AudioDebugInfo | null;
onDismiss?: () => void;
title?: string;
}
function formatBytes(bytes?: number): string | null {
if (!bytes || bytes <= 0) {
return null;
}
if (bytes < 1024) {
return `${bytes} B`;
}
const units = ["KB", "MB", "GB"] as const;
let value = bytes;
let unitIndex = -1;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex += 1;
}
return `${value.toFixed(value >= 100 ? 0 : 1)} ${units[unitIndex]}`;
}
function formatDuration(duration?: number): string | null {
if (!duration || duration <= 0) {
return null;
}
const seconds = duration / 1000;
if (seconds < 1) {
return `${(seconds * 1000).toFixed(0)} ms`;
}
return `${seconds.toFixed(seconds >= 10 ? 0 : 1)} s`;
}
export function AudioDebugNotice({
info,
onDismiss,
title = "Dictation Debug",
}: AudioDebugNoticeProps) {
const { theme } = useUnistyles();
const [copied, setCopied] = useState(false);
const stats = useMemo(() => {
if (!info) {
return null;
}
const parts: string[] = [];
if (info.format) {
parts.push(info.format);
}
const size = formatBytes(info.byteLength);
if (size) {
parts.push(size);
}
const duration = formatDuration(info.duration);
if (duration) {
parts.push(duration);
}
if (info.avgLogprob !== undefined) {
const label = `${info.avgLogprob.toFixed(2)} avg logprob`;
parts.push(info.isLowConfidence ? `${label} (low confidence)` : label);
}
return parts.join(" · ");
}, [info]);
if (!info) {
return null;
}
const handleCopyPath = async () => {
if (!info.debugRecordingPath) {
return;
}
try {
await Clipboard.setStringAsync(info.debugRecordingPath);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch (error) {
console.warn("[AudioDebug] Failed to copy path", error);
}
};
const pathMissing = !info.debugRecordingPath;
return (
<View
style={[
styles.container,
{
backgroundColor: theme.colors.surface2,
borderColor: theme.colors.border,
},
]}
>
<View style={styles.headerRow}>
<Text style={[styles.title, { color: theme.colors.foregroundMuted }]}>{title}</Text>
{onDismiss ? (
<Pressable accessibilityLabel="Dismiss audio debug" onPress={onDismiss} hitSlop={8}>
<X size={14} color={theme.colors.foregroundMuted} />
</Pressable>
) : null}
</View>
{info.debugRecordingPath ? (
<Pressable
style={styles.pathRow}
onPress={handleCopyPath}
accessibilityLabel="Copy raw audio path"
>
<Text numberOfLines={2} style={[styles.pathText, { color: theme.colors.foreground }]}>
{info.debugRecordingPath}
</Text>
<View style={[styles.copyPill, { backgroundColor: theme.colors.primary }]}>
{copied ? (
<Check size={12} color={theme.colors.primaryForeground} />
) : (
<Copy size={12} color={theme.colors.primaryForeground} />
)}
</View>
</Pressable>
) : (
<Text style={[styles.hint, { color: theme.colors.foregroundMuted }]}>
Raw audio path unavailable. Set STT_DEBUG_AUDIO_DIR on the server to persist recordings.
</Text>
)}
{stats ? (
<Text style={[styles.stats, { color: theme.colors.foregroundMuted }]}>{stats}</Text>
) : null}
</View>
);
}
const styles = StyleSheet.create({
container: {
borderRadius: 10,
borderWidth: StyleSheet.hairlineWidth,
padding: 12,
gap: 8,
},
headerRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
title: {
fontSize: 12,
fontWeight: "600",
textTransform: "uppercase",
},
pathRow: {
flexDirection: "row",
alignItems: "center",
gap: 8,
},
pathText: {
flex: 1,
fontSize: 13,
},
copyPill: {
width: 28,
height: 28,
borderRadius: 14,
alignItems: "center",
justifyContent: "center",
},
stats: {
fontSize: 12,
},
hint: {
fontSize: 12,
},
});

View File

@@ -1,50 +0,0 @@
import { View, Text } from "react-native";
import { StyleSheet } from "react-native-unistyles";
interface ConnectionStatusProps {
isConnected: boolean;
}
const styles = StyleSheet.create((theme) => ({
container: {
// No padding or border - parent handles layout
},
row: {
flexDirection: "row",
alignItems: "center",
},
dot: {
width: 8,
height: 8,
borderRadius: theme.borderRadius.full,
marginRight: theme.spacing[2],
},
dotConnected: {
backgroundColor: theme.colors.palette.green[500],
},
dotDisconnected: {
backgroundColor: theme.colors.destructive,
},
text: {
fontSize: theme.fontSize.sm,
},
textConnected: {
color: theme.colors.palette.green[500],
},
textDisconnected: {
color: theme.colors.destructive,
},
}));
export function ConnectionStatus({ isConnected }: ConnectionStatusProps) {
return (
<View style={styles.container}>
<View style={styles.row}>
<View style={[styles.dot, isConnected ? styles.dotConnected : styles.dotDisconnected]} />
<Text style={[styles.text, isConnected ? styles.textConnected : styles.textDisconnected]}>
{isConnected ? "Connected" : "Disconnected"}
</Text>
</View>
</View>
);
}

View File

@@ -1,173 +0,0 @@
import { View, Text, Pressable } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import {
AlertTriangle,
CheckCircle2,
Info,
RefreshCcw,
RotateCcw,
WifiOff,
X,
} from "lucide-react-native";
export type DictationToastVariant = "info" | "success" | "warning" | "error";
interface DictationStatusNoticeProps {
variant: DictationToastVariant;
title: string;
subtitle?: string;
meta?: string;
actionLabel?: string;
onAction?: () => void;
onDismiss?: () => void;
}
const variantIconMap: Record<DictationToastVariant, typeof Info> = {
info: RefreshCcw,
success: CheckCircle2,
warning: AlertTriangle,
error: AlertTriangle,
};
export function DictationStatusNotice({
variant,
title,
subtitle,
meta,
actionLabel,
onAction,
onDismiss,
}: DictationStatusNoticeProps) {
const { theme } = useUnistyles();
const VariantIcon = (() => {
if (variant === "warning" && title.toLowerCase().includes("offline")) {
return WifiOff;
}
return variantIconMap[variant] ?? Info;
})();
const backgroundColor = (() => {
switch (variant) {
case "success":
return theme.colors.palette.green[500];
case "warning":
return theme.colors.palette.amber[500];
case "error":
return theme.colors.palette.red[500];
default:
return theme.colors.surface0;
}
})();
const foregroundColor = variant === "info" ? theme.colors.foreground : theme.colors.palette.white;
const secondaryColor =
variant === "info" ? theme.colors.foregroundMuted : theme.colors.palette.white;
return (
<View
style={[
styles.container,
{
backgroundColor,
borderColor: variant === "info" ? theme.colors.border : "transparent",
},
]}
>
<View style={styles.headerRow}>
<View style={styles.titleRow}>
<VariantIcon size={16} color={foregroundColor} />
<Text style={[styles.title, { color: foregroundColor }]}>{title}</Text>
</View>
{onDismiss ? (
<Pressable accessibilityLabel="Dismiss dictation status" hitSlop={8} onPress={onDismiss}>
<X size={14} color={foregroundColor} />
</Pressable>
) : null}
</View>
{subtitle ? (
<Text style={[styles.subtitle, { color: secondaryColor }]}>{subtitle}</Text>
) : null}
{(meta || (actionLabel && onAction)) && (
<View style={styles.actionsRow}>
{meta ? <Text style={[styles.meta, { color: secondaryColor }]}>{meta}</Text> : <View />}
{actionLabel && onAction ? (
<Pressable
style={[
styles.actionButton,
{
backgroundColor: variant === "info" ? theme.colors.primary : "rgba(0,0,0,0.2)",
},
]}
onPress={onAction}
accessibilityRole="button"
accessibilityLabel={actionLabel}
>
<RotateCcw
size={14}
color={variant === "info" ? theme.colors.primaryForeground : foregroundColor}
/>
<Text
style={[
styles.actionText,
{ color: variant === "info" ? theme.colors.primaryForeground : foregroundColor },
]}
>
{actionLabel}
</Text>
</Pressable>
) : null}
</View>
)}
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
borderWidth: StyleSheet.hairlineWidth,
borderRadius: theme.borderRadius.xl,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
gap: theme.spacing[2],
},
headerRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
},
titleRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
title: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
},
subtitle: {
fontSize: theme.fontSize.sm,
},
actionsRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
meta: {
fontSize: theme.fontSize.xs,
},
actionButton: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
borderRadius: theme.borderRadius.full,
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[1],
},
actionText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
},
}));

View File

@@ -1,84 +0,0 @@
import { View, Text, Pressable } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Plus, Download } from "lucide-react-native";
interface EmptyStateProps {
onCreateAgent: () => void;
onImportAgent?: () => void;
}
export function EmptyState({ onCreateAgent, onImportAgent }: EmptyStateProps) {
const { theme } = useUnistyles();
const hasImportCta = typeof onImportAgent === "function";
return (
<View style={styles.container}>
<Text style={styles.title}>Hammock</Text>
<Text style={styles.subtitle}>What would you like to work on?</Text>
<View style={styles.buttonGroup}>
<Pressable onPress={onCreateAgent} style={[styles.button, styles.primaryButton]}>
<Plus size={20} color={styles.primaryButtonText.color} />
<Text style={styles.primaryButtonText}>New agent</Text>
</Pressable>
{hasImportCta ? (
<Pressable onPress={onImportAgent} style={[styles.button, styles.secondaryButton]}>
<Download size={20} color={styles.secondaryButtonText.color} />
<Text style={styles.secondaryButtonText}>Import agent</Text>
</Pressable>
) : null}
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: theme.spacing[6],
},
title: {
fontSize: theme.fontSize["4xl"],
fontWeight: "700",
color: theme.colors.foreground,
marginBottom: theme.spacing[2],
},
subtitle: {
fontSize: theme.fontSize.lg,
color: theme.colors.foregroundMuted,
textAlign: "center",
marginBottom: theme.spacing[8],
},
buttonGroup: {
width: "100%",
gap: theme.spacing[3],
},
button: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[6],
borderRadius: theme.borderRadius.lg,
justifyContent: "center",
},
primaryButton: {
backgroundColor: theme.colors.primary,
},
primaryButtonText: {
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.primaryForeground,
},
secondaryButton: {
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface2,
},
secondaryButtonText: {
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.foreground,
},
}));

View File

@@ -1,92 +0,0 @@
import { View, Text, Modal, Pressable } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import type { Agent } from "@/contexts/session-context";
interface ModeSelectorModalProps {
visible: boolean;
agent: Agent | null;
onModeChange: (modeId: string) => void;
onClose: () => void;
}
export function ModeSelectorModal({
visible,
agent,
onModeChange,
onClose,
}: ModeSelectorModalProps) {
const { theme } = useUnistyles();
return (
<Modal visible={visible} animationType="fade" transparent={true} onRequestClose={onClose}>
<Pressable style={styles.modalOverlay} onPress={onClose}>
<View style={styles.modeSelectorContent}>
{agent?.availableModes?.map((mode) => {
const isActive = mode.id === agent.currentModeId;
return (
<Pressable
key={mode.id}
onPress={() => {
onModeChange(mode.id);
onClose();
}}
style={[styles.modeItem, isActive && styles.modeItemActive]}
>
<Text style={[styles.modeName, isActive && styles.modeNameActive]}>
{mode.label}
</Text>
{mode.description && (
<Text style={[styles.modeDescription, isActive && styles.modeDescriptionActive]}>
{mode.description}
</Text>
)}
</Pressable>
);
})}
</View>
</Pressable>
</Modal>
);
}
const styles = StyleSheet.create((theme) => ({
modalOverlay: {
flex: 1,
backgroundColor: "rgba(0,0,0,0.5)",
justifyContent: "center",
alignItems: "center",
},
modeSelectorContent: {
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
padding: theme.spacing[4],
minWidth: 280,
maxWidth: 320,
},
modeItem: {
padding: theme.spacing[4],
borderRadius: theme.borderRadius.md,
marginBottom: theme.spacing[2],
backgroundColor: theme.colors.surface2,
},
modeItemActive: {
backgroundColor: theme.colors.primary,
},
modeName: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
marginBottom: theme.spacing[1],
},
modeNameActive: {
color: theme.colors.primaryForeground,
},
modeDescription: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
modeDescriptionActive: {
color: theme.colors.primaryForeground,
opacity: 0.8,
},
}));

View File

@@ -1,236 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Dimensions, Platform, StatusBar, type View } from "react-native";
export type Placement = "top" | "bottom" | "left" | "right";
export type Alignment = "start" | "center" | "end";
interface Rect {
x: number;
y: number;
width: number;
height: number;
}
interface FloatingStyles {
position: "absolute";
top: number;
left: number;
}
interface GeometryResult {
popoverOrigin: { x: number; y: number };
placement: Placement;
availableSize: { width: number; height: number };
}
function measureElement(element: View): Promise<Rect> {
return new Promise((resolve) => {
element.measureInWindow((x, y, width, height) => {
resolve({ x, y, width, height });
});
});
}
function computeGeometry({
fromRect,
contentSize,
displayArea,
placement,
alignment,
offset,
padding,
}: {
fromRect: Rect;
contentSize: { width: number; height: number };
displayArea: Rect;
placement: Placement;
alignment: Alignment;
offset: number;
padding: number;
}): GeometryResult {
const { width: contentWidth, height: contentHeight } = contentSize;
// Calculate available space in each direction
const spaceTop = fromRect.y - displayArea.y - padding;
const spaceBottom = displayArea.y + displayArea.height - (fromRect.y + fromRect.height) - padding;
const spaceLeft = fromRect.x - displayArea.x - padding;
const spaceRight = displayArea.x + displayArea.width - (fromRect.x + fromRect.width) - padding;
// Determine actual placement (may flip if not enough space)
let actualPlacement = placement;
if (placement === "bottom" && spaceBottom < contentHeight && spaceTop > spaceBottom) {
actualPlacement = "top";
} else if (placement === "top" && spaceTop < contentHeight && spaceBottom > spaceTop) {
actualPlacement = "bottom";
} else if (placement === "left" && spaceLeft < contentWidth && spaceRight > spaceLeft) {
actualPlacement = "right";
} else if (placement === "right" && spaceRight < contentWidth && spaceLeft > spaceRight) {
actualPlacement = "left";
}
let x: number;
let y: number;
// Position based on placement
if (actualPlacement === "bottom") {
y = fromRect.y + fromRect.height + offset;
} else if (actualPlacement === "top") {
y = fromRect.y - contentHeight - offset;
} else if (actualPlacement === "left") {
x = fromRect.x - contentWidth - offset;
} else {
x = fromRect.x + fromRect.width + offset;
}
// Alignment on cross axis
if (actualPlacement === "top" || actualPlacement === "bottom") {
if (alignment === "start") {
x = fromRect.x;
} else if (alignment === "end") {
x = fromRect.x + fromRect.width - contentWidth;
} else {
x = fromRect.x + (fromRect.width - contentWidth) / 2;
}
} else {
if (alignment === "start") {
y = fromRect.y;
} else if (alignment === "end") {
y = fromRect.y + fromRect.height - contentHeight;
} else {
y = fromRect.y + (fromRect.height - contentHeight) / 2;
}
}
// Constrain to display area (shift)
const minX = displayArea.x + padding;
const maxX = displayArea.x + displayArea.width - contentWidth - padding;
const minY = displayArea.y + padding;
const maxY = displayArea.y + displayArea.height - contentHeight - padding;
x = Math.max(minX, Math.min(maxX, x!));
y = Math.max(minY, Math.min(maxY, y!));
// Calculate available size
const availableWidth = displayArea.width - padding * 2;
const availableHeight =
actualPlacement === "bottom"
? displayArea.y + displayArea.height - (fromRect.y + fromRect.height) - offset - padding
: actualPlacement === "top"
? fromRect.y - displayArea.y - offset - padding
: displayArea.height - padding * 2;
return {
popoverOrigin: { x, y },
placement: actualPlacement,
availableSize: {
width: Math.max(0, availableWidth),
height: Math.max(0, availableHeight),
},
};
}
export interface UseDropdownFloatingOptions {
open: boolean;
placement?: Placement;
alignment?: Alignment;
offset?: number;
padding?: number;
referenceEl: View | null;
}
export interface UseDropdownFloatingReturn {
floatingRef: (el: View | null) => void;
floatingStyles: FloatingStyles;
update: () => void;
onLayout: () => void;
availableSize: { width: number; height: number } | null;
actualPlacement: Placement;
}
export function useDropdownFloating({
open,
placement = "bottom",
alignment = "start",
offset = 8,
padding = 8,
referenceEl,
}: UseDropdownFloatingOptions): UseDropdownFloatingReturn {
const floatingElRef = useRef<View | null>(null);
const [geometry, setGeometry] = useState<GeometryResult | null>(null);
const displayArea = useMemo(() => {
const { width, height } = Dimensions.get("window");
const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
return {
x: 0,
y: statusBarHeight,
width,
height: height - statusBarHeight,
};
}, []);
const update = useCallback(async () => {
if (!referenceEl || !floatingElRef.current) {
return;
}
try {
const [fromRect, contentRect] = await Promise.all([
measureElement(referenceEl),
measureElement(floatingElRef.current),
]);
const result = computeGeometry({
fromRect,
contentSize: { width: contentRect.width, height: contentRect.height },
displayArea,
placement,
alignment,
offset,
padding,
});
setGeometry(result);
} catch (e) {
console.warn("[useDropdownFloating] measure failed:", e);
}
}, [referenceEl, displayArea, placement, alignment, offset, padding]);
const floatingRef = useCallback((el: View | null) => {
floatingElRef.current = el;
}, []);
// Track when floating element is ready
const [floatingReady, setFloatingReady] = useState(false);
const handleFloatingLayout = useCallback(() => {
setFloatingReady(true);
}, []);
useEffect(() => {
if (!open) {
setGeometry(null);
setFloatingReady(false);
return;
}
if (floatingReady && referenceEl && floatingElRef.current) {
update();
}
}, [open, floatingReady, referenceEl, update]);
const floatingStyles: FloatingStyles = {
position: "absolute",
top: geometry?.popoverOrigin.y ?? 0,
left: geometry?.popoverOrigin.x ?? 0,
};
return {
floatingRef,
floatingStyles,
update,
onLayout: handleFloatingLayout,
availableSize: geometry?.availableSize ?? null,
actualPlacement: geometry?.placement ?? placement,
};
}

View File

@@ -1,222 +0,0 @@
import { Pressable, View, Text, Animated } from "react-native";
import { useEffect, useRef } from "react";
import { StyleSheet } from "react-native-unistyles";
interface VoiceButtonProps {
state: "idle" | "recording" | "processing" | "playing";
onPress: () => void;
disabled?: boolean;
}
const styles = StyleSheet.create((theme) => ({
container: {
alignItems: "center",
gap: theme.spacing[4],
},
pressable: {
opacity: 1,
},
pressableDisabled: {
opacity: 0.5,
},
button: {
width: 80,
height: 80,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
...theme.shadow.md,
},
buttonIdle: {
backgroundColor: theme.colors.surface2,
},
buttonRecording: {
backgroundColor: theme.colors.destructive,
},
buttonProcessing: {
backgroundColor: theme.colors.primary,
},
buttonPlaying: {
backgroundColor: theme.colors.palette.green[500],
},
label: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
// Recording icon
recordingIcon: {
width: 24,
height: 24,
backgroundColor: theme.colors.foreground,
borderRadius: theme.borderRadius.md,
},
// Processing icon
processingIconContainer: {
width: 24,
height: 24,
},
processingDot: {
width: 6,
height: 6,
backgroundColor: theme.colors.primaryForeground,
borderRadius: theme.borderRadius.full,
position: "absolute",
},
processingDotTop: {
top: 0,
left: 12,
},
processingDotRight: {
top: 12,
right: 0,
},
processingDotBottom: {
bottom: 0,
left: 12,
},
// Playing icon
playingIconContainer: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
playingBar: {
backgroundColor: theme.colors.foreground,
borderRadius: theme.borderRadius.sm,
},
playingBar1: {
width: 4,
height: 16,
},
playingBar2: {
width: 4,
height: 24,
},
playingBar3: {
width: 4,
height: 12,
},
// Idle icon (microphone)
micContainer: {
width: 24,
height: 32,
position: "relative",
},
micCapsule: {
position: "absolute",
bottom: 0,
left: 4,
width: 16,
height: 24,
backgroundColor: theme.colors.foreground,
borderTopLeftRadius: 999,
borderTopRightRadius: 999,
},
micBase: {
position: "absolute",
bottom: 0,
left: 0,
width: 24,
height: 6,
backgroundColor: theme.colors.foreground,
borderRadius: theme.borderRadius.full,
},
}));
export function VoiceButton({ state, onPress, disabled = false }: VoiceButtonProps) {
const pulseAnim = useRef(new Animated.Value(1)).current;
useEffect(() => {
if (state === "recording") {
Animated.loop(
Animated.sequence([
Animated.timing(pulseAnim, {
toValue: 1.2,
duration: 800,
useNativeDriver: true,
}),
Animated.timing(pulseAnim, {
toValue: 1,
duration: 800,
useNativeDriver: true,
}),
]),
).start();
} else {
pulseAnim.setValue(1);
}
}, [state, pulseAnim]);
const getButtonStyle = () => {
switch (state) {
case "recording":
return styles.buttonRecording;
case "processing":
return styles.buttonProcessing;
case "playing":
return styles.buttonPlaying;
default:
return styles.buttonIdle;
}
};
const getIcon = () => {
switch (state) {
case "recording":
return <View style={styles.recordingIcon} />;
case "processing":
return (
<View style={styles.processingIconContainer}>
<View style={[styles.processingDot, styles.processingDotTop]} />
<View style={[styles.processingDot, styles.processingDotRight]} />
<View style={[styles.processingDot, styles.processingDotBottom]} />
</View>
);
case "playing":
return (
<View style={styles.playingIconContainer}>
<View style={[styles.playingBar, styles.playingBar1]} />
<View style={[styles.playingBar, styles.playingBar2]} />
<View style={[styles.playingBar, styles.playingBar3]} />
</View>
);
default:
return (
<View style={styles.micContainer}>
<View style={styles.micCapsule} />
<View style={styles.micBase} />
</View>
);
}
};
const getLabel = () => {
switch (state) {
case "recording":
return "Recording...";
case "processing":
return "Processing...";
case "playing":
return "Playing...";
default:
return "Tap to speak";
}
};
return (
<View style={styles.container}>
<Pressable
onPress={onPress}
disabled={disabled}
style={disabled ? styles.pressableDisabled : styles.pressable}
>
<Animated.View
style={[styles.button, getButtonStyle(), { transform: [{ scale: pulseAnim }] }]}
>
{getIcon()}
</Animated.View>
</Pressable>
<Text style={styles.label}>{getLabel()}</Text>
</View>
);
}

View File

@@ -1,118 +0,0 @@
import { ActivityIndicator, Alert, Pressable, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Mic, MicOff, Square } from "lucide-react-native";
import { VolumeMeter } from "@/components/volume-meter";
import { useVoice, useVoiceTelemetry } from "@/contexts/voice-context";
export function VoiceCompactIndicator() {
const { theme } = useUnistyles();
const { volume, isSpeaking } = useVoiceTelemetry();
const { isVoiceMode, isVoiceSwitching, isMuted, toggleMute, stopVoice } = useVoice();
if (!isVoiceMode) {
return null;
}
return (
<View style={[styles.container, isMuted && styles.containerMuted]}>
<View style={styles.meterContainer}>
<VolumeMeter
volume={volume}
isMuted={isMuted}
isSpeaking={isSpeaking}
orientation="horizontal"
variant="compact"
/>
</View>
<View style={styles.controlsRow}>
<Pressable
onPress={toggleMute}
disabled={isVoiceSwitching}
accessibilityRole="button"
accessibilityLabel={isMuted ? "Unmute voice" : "Mute voice"}
style={[styles.muteButton, isVoiceSwitching ? styles.buttonDisabled : undefined]}
hitSlop={8}
>
{isMuted ? (
<MicOff size={14} color={theme.colors.palette.white} />
) : (
<Mic size={14} color={theme.colors.foreground} />
)}
</Pressable>
<Pressable
onPress={() => {
void stopVoice().catch((error) => {
console.error("[VoiceCompactIndicator] Failed to stop voice mode", error);
Alert.alert("Voice failed", "Unable to stop realtime voice mode.");
});
}}
disabled={isVoiceSwitching}
accessibilityRole="button"
accessibilityLabel="Disable realtime voice mode"
style={[styles.stopButton, isVoiceSwitching ? styles.buttonDisabled : undefined]}
hitSlop={8}
>
{isVoiceSwitching ? (
<ActivityIndicator size="small" color={theme.colors.palette.white} />
) : (
<Square
size={14}
color={theme.colors.palette.white}
fill={theme.colors.palette.white}
/>
)}
</Pressable>
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
paddingLeft: theme.spacing[3],
paddingRight: theme.spacing[1],
height: 32,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.surface2,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
},
containerMuted: {
backgroundColor: theme.colors.palette.red[600],
borderWidth: 0,
},
meterContainer: {
justifyContent: "center",
},
controlsRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
muteButton: {
width: 28,
height: 28,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
backgroundColor: "transparent",
borderWidth: 0,
},
stopButton: {
width: 28,
height: 28,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
backgroundColor: theme.colors.palette.red[600],
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.palette.red[800],
},
buttonDisabled: {
opacity: 0.5,
},
}));

View File

@@ -1,105 +0,0 @@
import { View, Pressable } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { MicOff, Square } from "lucide-react-native";
import { VolumeMeter } from "./volume-meter";
import { useVoice, useVoiceTelemetry } from "@/contexts/voice-context";
import { useHosts } from "@/runtime/host-runtime";
export function VoicePanel() {
const { theme } = useUnistyles();
const daemons = useHosts();
const { volume, isSpeaking } = useVoiceTelemetry();
const { isMuted, stopVoice, toggleMute, activeServerId } = useVoice();
const hostLabel = activeServerId
? (daemons.find((daemon) => daemon.serverId === activeServerId)?.label ?? null)
: null;
const hostSuffix = hostLabel ? ` (${hostLabel})` : "";
return (
<View style={styles.container}>
<View style={styles.contentRow}>
<View style={styles.meterContainer}>
<VolumeMeter
volume={volume}
isMuted={isMuted}
isSpeaking={isSpeaking}
orientation="horizontal"
variant="compact"
/>
</View>
<View style={styles.actionsRow}>
<Pressable
onPress={toggleMute}
accessibilityRole="button"
accessibilityLabel={`${isMuted ? "Unmute voice" : "Mute voice"}${hostSuffix}`}
style={[styles.iconButton, isMuted && styles.iconButtonMuted]}
>
<MicOff
size={18}
color={isMuted ? theme.colors.palette.white : theme.colors.foreground}
/>
</Pressable>
<Pressable
onPress={() => void stopVoice()}
accessibilityRole="button"
accessibilityLabel={`Stop voice mode${hostSuffix}`}
style={[styles.iconButton, styles.iconButtonStop]}
>
<Square size={16} color="white" fill="white" />
</Pressable>
</View>
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
marginHorizontal: theme.spacing[4],
marginBottom: theme.spacing[3],
borderRadius: theme.borderRadius["2xl"],
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface2,
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
},
contentRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[3],
},
meterContainer: {
flex: 1,
justifyContent: "center",
alignItems: "flex-start",
paddingLeft: theme.spacing[1],
},
actionsRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "flex-end",
gap: theme.spacing[2],
},
iconButton: {
width: 40,
height: 40,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
backgroundColor: theme.colors.surface0,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
},
iconButtonMuted: {
backgroundColor: theme.colors.palette.red[500],
borderWidth: 0,
},
iconButtonStop: {
backgroundColor: theme.colors.palette.red[600],
borderColor: theme.colors.palette.red[800],
},
}));

View File

@@ -1,2 +0,0 @@
export const AUDIO_DEBUG_ENABLED =
typeof process !== "undefined" && process.env?.EXPO_PUBLIC_ENABLE_AUDIO_DEBUG === "1";

View File

@@ -1 +0,0 @@
export * from "./use-dictation";

View File

@@ -1,71 +0,0 @@
import { useState, useEffect, useCallback } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
const STORAGE_KEY = "@paseo:recent-paths";
const MAX_RECENT_PATHS = 3;
export interface UseRecentPathsReturn {
recentPaths: string[];
isLoading: boolean;
addRecentPath: (path: string) => Promise<void>;
clearRecentPaths: () => Promise<void>;
}
export function useRecentPaths(): UseRecentPathsReturn {
const [recentPaths, setRecentPaths] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(true);
// Load recent paths from AsyncStorage on mount
useEffect(() => {
loadRecentPaths();
}, []);
async function loadRecentPaths() {
try {
const stored = await AsyncStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored) as string[];
setRecentPaths(parsed);
}
} catch (error) {
console.error("[RecentPaths] Failed to load recent paths:", error);
// Continue with empty array
} finally {
setIsLoading(false);
}
}
const addRecentPath = useCallback(
async (path: string) => {
try {
// Remove duplicates and add to front
const filtered = recentPaths.filter((p) => p !== path);
const updated = [path, ...filtered].slice(0, MAX_RECENT_PATHS);
setRecentPaths(updated);
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
} catch (error) {
console.error("[RecentPaths] Failed to save recent path:", error);
throw error;
}
},
[recentPaths],
);
const clearRecentPaths = useCallback(async () => {
try {
setRecentPaths([]);
await AsyncStorage.removeItem(STORAGE_KEY);
} catch (error) {
console.error("[RecentPaths] Failed to clear recent paths:", error);
throw error;
}
}, []);
return {
recentPaths,
isLoading,
addRecentPath,
clearRecentPaths,
};
}

View File

@@ -1,100 +0,0 @@
import { useEffect, useMemo } from "react";
import { useQueries } from "@tanstack/react-query";
import {
checkoutStatusQueryKey,
type CheckoutStatusPayload,
CHECKOUT_STATUS_STALE_TIME,
} from "@/hooks/use-checkout-status-query";
import { groupAgents } from "@/utils/agent-grouping";
import { useSectionOrderStore, sortProjectsByStoredOrder } from "@/stores/section-order-store";
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
export interface SidebarSectionData {
key: string;
projectKey: string;
title: string;
agents: AggregatedAgent[];
/** For project sections, the first agent's serverId (to lookup checkout status) */
firstAgentServerId?: string;
/** For project sections, the first agent's id (to lookup checkout status) */
firstAgentId?: string;
/** Working directory for the project (from first agent) */
workingDir?: string;
}
export function useSidebarAgentSections(agents: AggregatedAgent[]): SidebarSectionData[] {
// Subscribe to checkout status cache entries for each visible agent so that grouping
// can switch from cwd→remote as soon as checkout status is prefetched.
//
// This avoids a brief UI state where two separate sections can render with the
// same icon/title while grouping is still keyed by cwd.
const checkoutStatusQueries = useQueries({
queries: agents.map((agent) => ({
queryKey: checkoutStatusQueryKey(agent.serverId, agent.cwd),
queryFn: async (): Promise<CheckoutStatusPayload> => {
throw new Error("Checkout status query is disabled in sidebar grouping");
},
enabled: false,
staleTime: CHECKOUT_STATUS_STALE_TIME,
})),
});
const remoteUrlByAgentKey = useMemo(() => {
const result = new Map<string, string | null>();
for (let idx = 0; idx < agents.length; idx++) {
const agent = agents[idx];
const checkout = checkoutStatusQueries[idx]?.data ?? null;
result.set(`${agent.serverId}:${agent.id}`, checkout?.remoteUrl ?? null);
}
return result;
}, [agents, checkoutStatusQueries]);
const projectOrder = useSectionOrderStore((state) => state.projectOrder);
const setProjectOrder = useSectionOrderStore((state) => state.setProjectOrder);
const { activeGroups } = useMemo(
() =>
groupAgents(agents, {
getRemoteUrl: (agent) => remoteUrlByAgentKey.get(`${agent.serverId}:${agent.id}`) ?? null,
}),
[agents, remoteUrlByAgentKey],
);
const sortedGroups = useMemo(
() => sortProjectsByStoredOrder(activeGroups, projectOrder),
[activeGroups, projectOrder],
);
const sections: SidebarSectionData[] = useMemo(() => {
const result: SidebarSectionData[] = [];
for (const group of sortedGroups) {
const sectionKey = `project:${group.projectKey}`;
const firstAgent = group.agents[0];
result.push({
key: sectionKey,
projectKey: group.projectKey,
title: group.projectName,
agents: group.agents,
firstAgentServerId: firstAgent?.serverId,
firstAgentId: firstAgent?.id,
workingDir: firstAgent?.cwd,
});
}
return result;
}, [sortedGroups]);
// Sync section order when new projects appear.
useEffect(() => {
const currentKeys = sections.map((s) => s.projectKey);
const storedKeys = new Set(projectOrder);
const newKeys = currentKeys.filter((key) => !storedKeys.has(key));
if (newKeys.length > 0) {
setProjectOrder([...projectOrder, ...newKeys]);
}
}, [projectOrder, sections, setProjectOrder]);
return sections;
}

View File

@@ -1,21 +0,0 @@
/**
* Learn more about light and dark modes:
* https://docs.expo.dev/guides/color-schemes/
*/
import { Colors } from "@/constants/theme";
import { useColorScheme } from "@/hooks/use-color-scheme";
export function useThemeColor(
props: { light?: string; dark?: string },
colorName: keyof typeof Colors.light & keyof typeof Colors.dark,
) {
const theme = useColorScheme() ?? "light";
const colorFromProps = props[theme];
if (colorFromProps) {
return colorFromProps;
} else {
return Colors[theme][colorName];
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,79 +0,0 @@
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
import AsyncStorage from "@react-native-async-storage/async-storage";
interface SectionOrderState {
/** Ordered array of project keys. Projects not in this list appear at the end in their natural order. */
projectOrder: string[];
/** Set the full order of project keys */
setProjectOrder: (order: string[]) => void;
/** Move a project to a new index */
moveProject: (fromIndex: number, toIndex: number) => void;
}
export const useSectionOrderStore = create<SectionOrderState>()(
persist(
(set) => ({
projectOrder: [],
setProjectOrder: (order) => set({ projectOrder: order }),
moveProject: (fromIndex, toIndex) =>
set((state) => {
const newOrder = [...state.projectOrder];
const [removed] = newOrder.splice(fromIndex, 1);
if (removed !== undefined) {
newOrder.splice(toIndex, 0, removed);
}
return { projectOrder: newOrder };
}),
}),
{
name: "section-order",
storage: createJSONStorage(() => AsyncStorage),
partialize: (state) => ({
projectOrder: state.projectOrder,
}),
},
),
);
/**
* Sort project groups according to persisted order.
* Projects not in the persisted order appear at the end in their original order.
*/
export function sortProjectsByStoredOrder<T extends { projectKey: string }>(
groups: T[],
storedOrder: string[],
): T[] {
if (storedOrder.length === 0) {
return groups;
}
const orderMap = new Map(storedOrder.map((key, index) => [key, index]));
return [...groups].sort((a, b) => {
const aIndex = orderMap.get(a.projectKey);
const bIndex = orderMap.get(b.projectKey);
// Both have stored order - sort by stored order
if (aIndex !== undefined && bIndex !== undefined) {
return aIndex - bIndex;
}
// Only a has stored order - a comes first
if (aIndex !== undefined) {
return -1;
}
// Only b has stored order - b comes first
if (bIndex !== undefined) {
return 1;
}
// Neither has stored order - maintain original order (stable sort)
return 0;
});
}

View File

@@ -1,39 +0,0 @@
import type { CheckoutStatusPayload } from "@/hooks/use-checkout-status-query";
/**
* Derives the branch label to display for an agent.
* Returns null if there's no branch to show (not a git repo, or on the base branch).
*/
export function deriveBranchLabel(checkout: CheckoutStatusPayload | null): string | null {
if (!checkout || !checkout.isGit) {
return null;
}
const currentBranch: string | null = checkout.currentBranch ?? null;
const baseRef: string | null = checkout.baseRef ?? null;
if (!currentBranch) {
return null;
}
if (currentBranch === "HEAD") {
return null;
}
if (baseRef && currentBranch === baseRef) {
return null;
}
return currentBranch;
}
/**
* Derives the project path to display for an agent.
* If inside a Paseo worktree, shows just the worktree-relative path.
* Otherwise uses the repo root or cwd.
*/
export function deriveProjectPath(cwd: string, checkout: CheckoutStatusPayload | null): string {
const basePath = checkout?.isGit ? (checkout.repoRoot ?? cwd) : cwd;
const worktreeMarker = ".paseo/worktrees/";
const idx = basePath.indexOf(worktreeMarker);
if (idx !== -1) {
const afterMarker = basePath.slice(idx + worktreeMarker.length);
return afterMarker;
}
return basePath;
}

View File

@@ -1,27 +0,0 @@
import type { Agent } from "@/contexts/session-context";
type AgentStatus = Agent["status"];
const STATUS_COLOR_MAP: Record<AgentStatus, string> = {
initializing: "#f59e0b",
idle: "#22c55e",
running: "#3b82f6",
error: "#ef4444",
closed: "#6b7280",
};
const STATUS_LABEL_MAP: Record<AgentStatus, string> = {
initializing: "Initializing",
idle: "Idle",
running: "Running",
error: "Error",
closed: "Closed",
};
export function getAgentStatusColor(status: AgentStatus): string {
return STATUS_COLOR_MAP[status] ?? "#9ca3af";
}
export function getAgentStatusLabel(status: AgentStatus): string {
return STATUS_LABEL_MAP[status] ?? "Unknown";
}

View File

@@ -1,20 +0,0 @@
type OfflineAction = "create" | "resume" | "dictation" | "import_list";
type AnalyticsEvent =
| {
type: "daemon_active_changed";
daemonId: string;
previousDaemonId: string | null;
source?: string;
}
| {
type: "offline_daemon_action_attempt";
action: OfflineAction;
daemonId: string | null;
status: string | null;
reason?: string | null;
};
export function trackAnalyticsEvent(_event: AnalyticsEvent) {
// Placeholder until a real analytics sink is wired in.
}

View File

@@ -1,565 +0,0 @@
import { isDev, isWeb } from "@/constants/platform";
type ListenerStats = {
adds: number;
removes: number;
active: number;
};
type TimerStats = {
created: number;
fired: number;
cleared: number;
active: number;
};
type WebSocketStats = {
created: number;
opened: number;
closed: number;
errored: number;
active: number;
};
type ComponentStats = {
mounts: number;
unmounts: number;
renders: number;
scrollEvents: number;
nearBottomTransitions: number;
metricUpdates: number;
itemRenderCalls: number;
wheelAttach: number;
wheelDetach: number;
inputChanges: number;
keyPresses: number;
lastRenderAtMs: number;
};
type ScrollInvestigationStore = {
markRender: (componentId: string) => void;
markEvent: (
componentId: string,
event:
| "mount"
| "unmount"
| "scrollEvent"
| "nearBottomTransition"
| "metricUpdate"
| "itemRenderCall"
| "wheelAttach"
| "wheelDetach"
| "inputChange"
| "keyPress",
) => void;
snapshot: () => {
listeners: {
byType: Record<string, ListenerStats>;
byCallsite: Record<string, number>;
activeUniqueKeys: number;
activeByTypeAndTarget: Record<string, Record<string, number>>;
};
timers: {
timeout: TimerStats;
interval: TimerStats;
raf: TimerStats;
};
websockets: {
totals: WebSocketStats;
activeByUrl: Record<string, number>;
};
components: Record<string, ComponentStats>;
};
printSnapshot: (label?: string) => void;
_installedAtMs: number;
};
type ScrollInvestigationGlobal = typeof globalThis & {
__PASEO_SCROLL_JANK_INVESTIGATION__?: ScrollInvestigationStore;
__PASEO_SCROLL_JANK_INVESTIGATION_DISABLED__?: boolean;
};
const TRACKED_EVENT_TYPES = new Set([
"wheel",
"scroll",
"pointermove",
"pointerup",
"pointercancel",
]);
const SOURCE_LABEL = "[ScrollJankInvestigation]";
function shouldInstall(): boolean {
const runtime = globalThis as ScrollInvestigationGlobal;
return isWeb && isDev && !runtime.__PASEO_SCROLL_JANK_INVESTIGATION_DISABLED__;
}
function normalizeCapture(options?: AddEventListenerOptions | boolean): boolean {
if (typeof options === "boolean") {
return options;
}
return Boolean(options?.capture);
}
function describeEventTarget(target: EventTarget): string {
const element = target as Element;
if (element && typeof element === "object" && "tagName" in element) {
const tagName = (element.tagName || "unknown").toLowerCase();
const testId = element.getAttribute?.("data-testid");
const role = element.getAttribute?.("role");
const id = (element as HTMLElement).id || null;
const className = (element as HTMLElement).className;
const classLabel =
typeof className === "string" && className.trim().length > 0
? className.trim().split(/\s+/).slice(0, 2).join(".")
: null;
const connectivityLabel =
typeof (element as Node).isConnected === "boolean"
? (element as Node).isConnected
? "[connected]"
: "[detached]"
: null;
return [
tagName,
id ? `#${id}` : null,
testId ? `[data-testid=${testId}]` : null,
role ? `[role=${role}]` : null,
classLabel ? `.${classLabel}` : null,
connectivityLabel,
]
.filter(Boolean)
.join("");
}
const ctorName = (target as { constructor?: { name?: string } }).constructor?.name;
return ctorName || "unknown-target";
}
function inferCallsite(): string {
const stack = new Error().stack;
if (!stack) {
return "unknown";
}
const frames = stack.split("\n");
for (const raw of frames.slice(2)) {
const line = raw.trim();
if (!line) {
continue;
}
if (line.includes("scroll-jank-investigation")) {
continue;
}
if (line.includes("patchedAddEventListener")) {
continue;
}
return line;
}
return "unknown";
}
function ensureListenerStats(map: Map<string, ListenerStats>, type: string): ListenerStats {
const existing = map.get(type);
if (existing) {
return existing;
}
const next: ListenerStats = { adds: 0, removes: 0, active: 0 };
map.set(type, next);
return next;
}
function ensureComponentStats(
map: Map<string, ComponentStats>,
componentId: string,
): ComponentStats {
const existing = map.get(componentId);
if (existing) {
return existing;
}
const next: ComponentStats = {
mounts: 0,
unmounts: 0,
renders: 0,
scrollEvents: 0,
nearBottomTransitions: 0,
metricUpdates: 0,
itemRenderCalls: 0,
wheelAttach: 0,
wheelDetach: 0,
inputChanges: 0,
keyPresses: 0,
lastRenderAtMs: 0,
};
map.set(componentId, next);
return next;
}
export function installScrollJankInvestigation(): void {
if (!shouldInstall()) {
return;
}
const runtime = globalThis as ScrollInvestigationGlobal;
if (runtime.__PASEO_SCROLL_JANK_INVESTIGATION__) {
return;
}
const targetIds = new WeakMap<object, number>();
const listenerIds = new WeakMap<object, number>();
const activeListenerKeys = new Set<string>();
const activeListenerMeta = new Map<string, { type: string; target: string }>();
const listenerStatsByType = new Map<string, ListenerStats>();
const listenerCallsiteCount = new Map<string, number>();
const componentStatsById = new Map<string, ComponentStats>();
const activeWsByUrl = new Map<string, number>();
const timeoutHandles = new Map<number, true>();
const intervalHandles = new Map<number, true>();
const rafHandles = new Map<number, true>();
let nextTargetId = 1;
let nextListenerId = 1;
const timerStats = {
timeout: { created: 0, fired: 0, cleared: 0, active: 0 } as TimerStats,
interval: { created: 0, fired: 0, cleared: 0, active: 0 } as TimerStats,
raf: { created: 0, fired: 0, cleared: 0, active: 0 } as TimerStats,
};
const websocketStats: WebSocketStats = {
created: 0,
opened: 0,
closed: 0,
errored: 0,
active: 0,
};
const eventTargetProto = EventTarget.prototype as EventTarget & {
addEventListener: EventTarget["addEventListener"];
removeEventListener: EventTarget["removeEventListener"];
};
const nativeAddEventListener = eventTargetProto.addEventListener;
const nativeRemoveEventListener = eventTargetProto.removeEventListener;
function getTargetId(target: EventTarget): string {
const targetObj = target as unknown as object;
const existing = targetIds.get(targetObj);
if (existing) {
return String(existing);
}
const next = nextTargetId++;
targetIds.set(targetObj, next);
return String(next);
}
function getListenerId(listener: EventListenerOrEventListenerObject): string {
const listenerObj = listener as unknown as object;
const existing = listenerIds.get(listenerObj);
if (existing) {
return String(existing);
}
const next = nextListenerId++;
listenerIds.set(listenerObj, next);
return String(next);
}
function toListenerKey(
target: EventTarget,
type: string,
listener: EventListenerOrEventListenerObject,
options?: AddEventListenerOptions | boolean,
): string {
return [
getTargetId(target),
type,
normalizeCapture(options) ? "capture" : "bubble",
getListenerId(listener),
].join("|");
}
eventTargetProto.addEventListener = function patchedAddEventListener(
this: EventTarget,
type: string,
listener: EventListenerOrEventListenerObject | null,
options?: AddEventListenerOptions | boolean,
): void {
nativeAddEventListener.call(this, type, listener as any, options as any);
if (!listener) {
return;
}
const stats = ensureListenerStats(listenerStatsByType, type);
stats.adds += 1;
const key = toListenerKey(this, type, listener, options);
if (!activeListenerKeys.has(key)) {
activeListenerKeys.add(key);
stats.active += 1;
activeListenerMeta.set(key, {
type,
target: describeEventTarget(this),
});
}
if (TRACKED_EVENT_TYPES.has(type)) {
const callsite = inferCallsite();
const metricKey = `${type} :: ${callsite}`;
listenerCallsiteCount.set(metricKey, (listenerCallsiteCount.get(metricKey) ?? 0) + 1);
}
};
eventTargetProto.removeEventListener = function patchedRemoveEventListener(
this: EventTarget,
type: string,
listener: EventListenerOrEventListenerObject | null,
options?: EventListenerOptions | boolean,
): void {
nativeRemoveEventListener.call(this, type, listener as any, options as any);
if (!listener) {
return;
}
const stats = ensureListenerStats(listenerStatsByType, type);
stats.removes += 1;
const key = toListenerKey(this, type, listener, options);
if (activeListenerKeys.delete(key)) {
stats.active = Math.max(0, stats.active - 1);
activeListenerMeta.delete(key);
}
};
const nativeSetTimeout = globalThis.setTimeout.bind(globalThis);
const nativeClearTimeout = globalThis.clearTimeout.bind(globalThis);
const nativeSetInterval = globalThis.setInterval.bind(globalThis);
const nativeClearInterval = globalThis.clearInterval.bind(globalThis);
const nativeRequestAnimationFrame = globalThis.requestAnimationFrame.bind(globalThis);
const nativeCancelAnimationFrame = globalThis.cancelAnimationFrame.bind(globalThis);
globalThis.setTimeout = ((handler: TimerHandler, timeout?: number, ...args: unknown[]) => {
timerStats.timeout.created += 1;
let timeoutId = -1;
const wrapped =
typeof handler === "function"
? (...handlerArgs: unknown[]) => {
if (timeoutHandles.delete(timeoutId)) {
timerStats.timeout.fired += 1;
timerStats.timeout.active = timeoutHandles.size;
}
return handler(...handlerArgs);
}
: handler;
timeoutId = nativeSetTimeout(wrapped, timeout, ...(args as any[])) as unknown as number;
timeoutHandles.set(timeoutId, true);
timerStats.timeout.active = timeoutHandles.size;
return timeoutId as unknown as ReturnType<typeof setTimeout>;
}) as unknown as typeof setTimeout;
globalThis.clearTimeout = ((timeoutId?: number) => {
if (typeof timeoutId === "number" && timeoutHandles.delete(timeoutId)) {
timerStats.timeout.cleared += 1;
timerStats.timeout.active = timeoutHandles.size;
}
return nativeClearTimeout(timeoutId);
}) as typeof clearTimeout;
globalThis.setInterval = ((handler: TimerHandler, timeout?: number, ...args: unknown[]) => {
timerStats.interval.created += 1;
let intervalId = -1;
const wrapped =
typeof handler === "function"
? (...handlerArgs: unknown[]) => {
timerStats.interval.fired += 1;
return handler(...handlerArgs);
}
: handler;
intervalId = nativeSetInterval(wrapped, timeout, ...(args as any[])) as unknown as number;
intervalHandles.set(intervalId, true);
timerStats.interval.active = intervalHandles.size;
return intervalId as unknown as ReturnType<typeof setInterval>;
}) as unknown as typeof setInterval;
globalThis.clearInterval = ((intervalId?: number) => {
if (typeof intervalId === "number" && intervalHandles.delete(intervalId)) {
timerStats.interval.cleared += 1;
timerStats.interval.active = intervalHandles.size;
}
return nativeClearInterval(intervalId);
}) as typeof clearInterval;
globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => {
timerStats.raf.created += 1;
let rafId = -1;
const wrapped = (timestamp: number) => {
if (rafHandles.delete(rafId)) {
timerStats.raf.fired += 1;
timerStats.raf.active = rafHandles.size;
}
callback(timestamp);
};
rafId = nativeRequestAnimationFrame(wrapped) as unknown as number;
rafHandles.set(rafId, true);
timerStats.raf.active = rafHandles.size;
return rafId as unknown as ReturnType<typeof requestAnimationFrame>;
}) as typeof requestAnimationFrame;
globalThis.cancelAnimationFrame = ((rafId: number) => {
if (rafHandles.delete(rafId)) {
timerStats.raf.cleared += 1;
timerStats.raf.active = rafHandles.size;
}
return nativeCancelAnimationFrame(rafId);
}) as typeof cancelAnimationFrame;
const NativeWebSocket = globalThis.WebSocket;
if (typeof NativeWebSocket === "function") {
class InstrumentedWebSocket extends NativeWebSocket {
constructor(url: string | URL, protocols?: string | string[]) {
if (protocols === undefined) {
super(url);
} else {
super(url, protocols);
}
const urlKey = String(url);
websocketStats.created += 1;
websocketStats.active += 1;
activeWsByUrl.set(urlKey, (activeWsByUrl.get(urlKey) ?? 0) + 1);
const handleOpen = () => {
websocketStats.opened += 1;
};
const handleError = () => {
websocketStats.errored += 1;
};
const handleClose = () => {
websocketStats.closed += 1;
websocketStats.active = Math.max(0, websocketStats.active - 1);
const current = activeWsByUrl.get(urlKey) ?? 0;
if (current <= 1) {
activeWsByUrl.delete(urlKey);
} else {
activeWsByUrl.set(urlKey, current - 1);
}
this.removeEventListener("open", handleOpen);
this.removeEventListener("error", handleError);
this.removeEventListener("close", handleClose);
};
this.addEventListener("open", handleOpen);
this.addEventListener("error", handleError);
this.addEventListener("close", handleClose);
}
}
globalThis.WebSocket = InstrumentedWebSocket as typeof WebSocket;
}
const store: ScrollInvestigationStore = {
markRender(componentId: string) {
const stats = ensureComponentStats(componentStatsById, componentId);
stats.renders += 1;
stats.lastRenderAtMs = performance.now();
},
markEvent(componentId: string, event) {
const stats = ensureComponentStats(componentStatsById, componentId);
switch (event) {
case "mount":
stats.mounts += 1;
return;
case "unmount":
stats.unmounts += 1;
return;
case "scrollEvent":
stats.scrollEvents += 1;
return;
case "nearBottomTransition":
stats.nearBottomTransitions += 1;
return;
case "metricUpdate":
stats.metricUpdates += 1;
return;
case "itemRenderCall":
stats.itemRenderCalls += 1;
return;
case "wheelAttach":
stats.wheelAttach += 1;
return;
case "wheelDetach":
stats.wheelDetach += 1;
return;
case "inputChange":
stats.inputChanges += 1;
return;
case "keyPress":
stats.keyPresses += 1;
return;
default:
return;
}
},
snapshot() {
const activeByTypeAndTarget: Record<string, Record<string, number>> = {};
for (const { type, target } of activeListenerMeta.values()) {
const existingByType = activeByTypeAndTarget[type] ?? {};
existingByType[target] = (existingByType[target] ?? 0) + 1;
activeByTypeAndTarget[type] = existingByType;
}
return {
listeners: {
byType: Object.fromEntries(listenerStatsByType.entries()),
byCallsite: Object.fromEntries(listenerCallsiteCount.entries()),
activeUniqueKeys: activeListenerKeys.size,
activeByTypeAndTarget,
},
timers: {
timeout: { ...timerStats.timeout, active: timeoutHandles.size },
interval: { ...timerStats.interval, active: intervalHandles.size },
raf: { ...timerStats.raf, active: rafHandles.size },
},
websockets: {
totals: { ...websocketStats },
activeByUrl: Object.fromEntries(activeWsByUrl.entries()),
},
components: Object.fromEntries(componentStatsById.entries()),
};
},
printSnapshot(label?: string) {
console.log(`${SOURCE_LABEL} ${label ?? "snapshot"}`, this.snapshot());
},
_installedAtMs: Date.now(),
};
runtime.__PASEO_SCROLL_JANK_INVESTIGATION__ = store;
console.log(
`${SOURCE_LABEL} installed`,
"Use window.__PASEO_SCROLL_JANK_INVESTIGATION__.snapshot()",
);
}
function getStore(): ScrollInvestigationStore | null {
const runtime = globalThis as ScrollInvestigationGlobal;
return runtime.__PASEO_SCROLL_JANK_INVESTIGATION__ ?? null;
}
export function markScrollInvestigationRender(componentId: string): void {
if (!shouldInstall()) {
return;
}
getStore()?.markRender(componentId);
}
export function markScrollInvestigationEvent(
componentId: string,
event:
| "mount"
| "unmount"
| "scrollEvent"
| "nearBottomTransition"
| "metricUpdate"
| "itemRenderCall"
| "wheelAttach"
| "wheelDetach"
| "inputChange"
| "keyPress",
): void {
if (!shouldInstall()) {
return;
}
getStore()?.markEvent(componentId, event);
}

View File

@@ -1,33 +0,0 @@
import { Asset } from "expo-asset";
import { File } from "expo-file-system";
import { isWeb } from "@/constants/platform";
export { parsePcm16Wav, type Pcm16Wav } from "@/utils/pcm16-wav";
export const THINKING_TONE_REPEAT_GAP_MS = 350;
let thinkingToneArrayBufferPromise: Promise<ArrayBuffer> | null = null;
async function readThinkingToneArrayBuffer(): Promise<ArrayBuffer> {
const toneModule = require("../../assets/audio/thinking-tone.wav");
const asset = Asset.fromModule(toneModule);
if (isWeb) {
const response = await fetch(asset.uri);
if (!response.ok) {
throw new Error(`Failed to fetch thinking tone asset: ${response.status}`);
}
return await response.arrayBuffer();
}
const resolvedAsset = asset.localUri ? asset : await asset.downloadAsync();
const fileUri = resolvedAsset.localUri ?? resolvedAsset.uri;
const file = new File(fileUri);
return await file.arrayBuffer();
}
export async function loadThinkingToneArrayBuffer(): Promise<ArrayBuffer> {
if (!thinkingToneArrayBufferPromise) {
thinkingToneArrayBufferPromise = readThinkingToneArrayBuffer();
}
return await thinkingToneArrayBufferPromise;
}

View File

@@ -1,136 +0,0 @@
/**
* TDD Test Suite for Command Support POC
*
* This tests the ability to:
* 1. Get available commands/skills from the Claude Agent SDK
* 2. Execute commands (determine if they're just prompts or something else)
*
* Key findings from SDK analysis:
* - `supportedCommands()` returns SlashCommand[] with name, description, argumentHint
* - Commands are executed by sending them as prompts with / prefix
* - The SDK init message includes `slash_commands: string[]` and `skills: string[]`
*
* IMPORTANT: Control methods like supportedCommands() work WITHOUT iterating the query first!
* Use an empty async generator for the prompt when you just need control methods.
* This pattern is used in claude-agent.ts listModels().
*/
import { beforeAll, beforeEach, describe, expect, test } from "vitest";
import { query, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
import { isCommandAvailable } from "../utils/executable.js";
const hasClaudeCredentials =
!!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY;
// Pattern from claude-agent.ts listModels():
// Use an empty async generator when you just need control methods
function createEmptyPrompt(): AsyncGenerator<SDKUserMessage, void, undefined> {
return (async function* empty() {})();
}
describe("Claude Agent SDK Commands POC", () => {
let canRunClaudeIntegration = false;
beforeAll(async () => {
canRunClaudeIntegration = (await isCommandAvailable("claude")) && hasClaudeCredentials;
});
beforeEach((context) => {
if (!canRunClaudeIntegration) {
context.skip();
}
});
describe("supportedCommands() API", () => {
test("should return an array of SlashCommand objects", async () => {
// Use the pattern from claude-agent.ts:
// Create a query with empty prompt generator for control methods
const emptyPrompt = createEmptyPrompt();
const claudeQuery = query({
prompt: emptyPrompt,
options: {
cwd: process.cwd(),
permissionMode: "plan",
includePartialMessages: false,
settingSources: ["user", "project"], // Required to load skills
},
});
try {
// supportedCommands() is a control method - works without iterating
const commands = await claudeQuery.supportedCommands();
// Should be an array
expect(Array.isArray(commands)).toBe(true);
// Verify structure
if (commands.length > 0) {
const firstCommand = commands[0];
expect(typeof firstCommand.name).toBe("string");
expect(typeof firstCommand.description).toBe("string");
expect(typeof firstCommand.argumentHint).toBe("string");
expect(firstCommand.name.startsWith("/")).toBe(false);
}
} finally {
if (typeof claudeQuery.return === "function") {
try {
await claudeQuery.return();
} catch {
// ignore shutdown errors
}
}
}
}, 30000);
test("should have valid SlashCommand structure for all commands", async () => {
const emptyPrompt = createEmptyPrompt();
const claudeQuery = query({
prompt: emptyPrompt,
options: {
cwd: process.cwd(),
permissionMode: "plan",
settingSources: ["user", "project"],
},
});
try {
const commands = await claudeQuery.supportedCommands();
expect(commands.length).toBeGreaterThan(0);
// Verify all commands have valid structure
for (const cmd of commands) {
expect(cmd).toHaveProperty("name");
expect(cmd).toHaveProperty("description");
expect(cmd).toHaveProperty("argumentHint");
expect(typeof cmd.name).toBe("string");
expect(typeof cmd.description).toBe("string");
expect(typeof cmd.argumentHint).toBe("string");
expect(cmd.name.length).toBeGreaterThan(0);
expect(cmd.name.startsWith("/")).toBe(false);
}
} finally {
await claudeQuery.return?.();
}
}, 30000);
});
describe("Command Execution", () => {
test("should explain that commands are prompts with / prefix", () => {
// This is a documentation test - commands ARE just prompts with / prefix
// To execute a command:
// 1. Create a user message with content: "/{commandName}"
// 2. Push it to the input stream
// 3. Iterate the query to receive responses
// The SDK handles command expansion:
// - Slash commands expand to their template content
// - Skills invoke the Skill tool
// - The model processes the expanded prompt like any other
expect(true).toBe(true); // Documentation-only test
});
});
});

View File

@@ -1,186 +0,0 @@
#!/usr/bin/env npx tsx
/**
* Investigation: What does command execution actually return?
*
* This script logs ALL message types and their full structure
* to understand how command output is delivered.
*/
import { query, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
class Pushable<T> implements AsyncIterable<T> {
private queue: T[] = [];
private resolvers: Array<(value: IteratorResult<T, void>) => void> = [];
private closed = false;
push(item: T) {
if (this.closed) return;
if (this.resolvers.length > 0) {
const resolve = this.resolvers.shift()!;
resolve({ value: item, done: false });
} else {
this.queue.push(item);
}
}
end() {
this.closed = true;
while (this.resolvers.length > 0) {
const resolve = this.resolvers.shift()!;
resolve({ value: undefined, done: true });
}
}
[Symbol.asyncIterator](): AsyncIterator<T, void> {
return {
next: (): Promise<IteratorResult<T, void>> => {
if (this.queue.length > 0) {
const value = this.queue.shift();
if (value !== undefined) {
return Promise.resolve({ value, done: false });
}
}
if (this.closed) {
return Promise.resolve({ value: undefined, done: true });
}
return new Promise<IteratorResult<T, void>>((resolve) => {
this.resolvers.push(resolve);
});
},
};
}
}
async function investigateCommand(commandName: string): Promise<void> {
process.stdout.write(`\n${"=".repeat(60)}\n`);
process.stdout.write(`Investigating: /${commandName}\n`);
process.stdout.write(`${"=".repeat(60)}\n`);
const input = new Pushable<SDKUserMessage>();
const claudeQuery = query({
prompt: input,
options: {
cwd: process.cwd(),
permissionMode: "plan",
includePartialMessages: true, // Include streaming partial messages
settingSources: ["user", "project"],
},
});
try {
const userMessage: SDKUserMessage = {
type: "user",
message: {
role: "user",
content: `/${commandName}`,
},
parent_tool_use_id: null,
session_id: "",
};
input.push(userMessage);
let messageCount = 0;
for await (const message of claudeQuery) {
messageCount++;
process.stdout.write(`\n--- Message ${messageCount} ---\n`);
process.stdout.write(`Type: ${message.type}\n`);
// Log the full structure based on type
switch (message.type) {
case "system":
process.stdout.write(`Subtype: ${message.subtype}\n`);
if (message.subtype === "init") {
process.stdout.write(`Session: ${message.session_id}\n`);
process.stdout.write(`Model: ${message.model}\n`);
}
break;
case "user":
process.stdout.write(
`User message content: ${JSON.stringify(message.message?.content, null, 2)}\n`,
);
break;
case "assistant":
process.stdout.write("Assistant message content:\n");
const content = message.message?.content;
if (Array.isArray(content)) {
for (const block of content) {
process.stdout.write(` Block type: ${block.type}\n`);
if (block.type === "text") {
process.stdout.write(` Text: ${block.text}\n`);
} else if (block.type === "tool_use") {
process.stdout.write(` Tool: ${block.name}\n`);
process.stdout.write(` Input: ${JSON.stringify(block.input, null, 2)}\n`);
} else {
process.stdout.write(` Full block: ${JSON.stringify(block, null, 2)}\n`);
}
}
} else {
process.stdout.write(` Content: ${JSON.stringify(content, null, 2)}\n`);
}
break;
case "stream_event":
process.stdout.write(`Stream event type: ${message.event?.type}\n`);
if (message.event?.type === "content_block_delta") {
const delta = message.event.delta;
if (delta?.type === "text_delta") {
process.stdout.write(` Text delta: ${delta.text}\n`);
}
}
break;
case "result":
process.stdout.write(`Result subtype: ${message.subtype}\n`);
if ("errors" in message && message.errors) {
process.stdout.write(`Errors: ${JSON.stringify(message.errors)}\n`);
}
// Check for any other properties
const resultKeys = Object.keys(message).filter((k) => !["type", "subtype"].includes(k));
if (resultKeys.length > 0) {
process.stdout.write(`Other result properties: ${resultKeys.join(", ")}\n`);
for (const key of resultKeys) {
process.stdout.write(` ${key}: ${JSON.stringify((message as any)[key], null, 2)}\n`);
}
}
break;
default:
process.stdout.write(`Full message: ${JSON.stringify(message, null, 2)}\n`);
}
if (message.type === "result") {
break;
}
}
process.stdout.write(`\nTotal messages received: ${messageCount}\n`);
} finally {
input.end();
await claudeQuery.return?.();
}
}
async function main() {
process.stdout.write("=== Command Output Investigation ===\n\n");
// Test /context - a local command that shows context info
await investigateCommand("context");
// Test /cost - another local command
await investigateCommand("cost");
// Test /prompt-engineer - a SKILL (not a local command)
await investigateCommand("prompt-engineer");
process.stdout.write("\n=== Investigation Complete ===\n");
}
main().catch((error) => {
process.stderr.write(`Fatal error: ${error}\n`);
process.exit(1);
});

View File

@@ -1,212 +0,0 @@
#!/usr/bin/env npx tsx
/**
* POC Script: Claude Agent SDK Commands
*
* This script demonstrates how to:
* 1. Get available slash commands/skills from the Claude Agent SDK
* 2. Execute commands (they are prompts sent with / prefix)
*
* Key insight from existing claude-agent.ts:
* - Control methods like supportedCommands() work WITHOUT iterating the query first
* - Use an empty async generator for the prompt when you just want to call control methods
* - Commands are executed by sending them as prompts with / prefix to a streaming query
*
* Usage: npx tsx src/poc-commands/run-poc.ts
*/
import { query, type SlashCommand, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
// Pattern from claude-agent.ts listModels():
// Use an empty async generator when you just need control methods
function createEmptyPrompt(): AsyncGenerator<SDKUserMessage, void, undefined> {
return (async function* empty() {})();
}
// Utility: Create a pushable stream for SDK input (for command execution demo)
class Pushable<T> implements AsyncIterable<T> {
private queue: T[] = [];
private resolvers: Array<(value: IteratorResult<T, void>) => void> = [];
private closed = false;
push(item: T) {
if (this.closed) return;
if (this.resolvers.length > 0) {
const resolve = this.resolvers.shift()!;
resolve({ value: item, done: false });
} else {
this.queue.push(item);
}
}
end() {
this.closed = true;
while (this.resolvers.length > 0) {
const resolve = this.resolvers.shift()!;
resolve({ value: undefined, done: true });
}
}
[Symbol.asyncIterator](): AsyncIterator<T, void> {
return {
next: (): Promise<IteratorResult<T, void>> => {
if (this.queue.length > 0) {
const value = this.queue.shift();
if (value !== undefined) {
return Promise.resolve({ value, done: false });
}
}
if (this.closed) {
return Promise.resolve({ value: undefined, done: true });
}
return new Promise<IteratorResult<T, void>>((resolve) => {
this.resolvers.push(resolve);
});
},
};
}
}
async function listAvailableCommands(): Promise<SlashCommand[]> {
// Use the pattern from claude-agent.ts listModels():
// Create a query with an empty prompt generator to call control methods
const emptyPrompt = createEmptyPrompt();
const claudeQuery = query({
prompt: emptyPrompt,
options: {
cwd: process.cwd(),
permissionMode: "plan",
includePartialMessages: false,
settingSources: ["user", "project"], // Required to load skills
},
});
try {
// supportedCommands() is a control method - works without iterating
const commands = await claudeQuery.supportedCommands();
return commands;
} finally {
// Clean up
if (typeof claudeQuery.return === "function") {
try {
await claudeQuery.return();
} catch {
// ignore shutdown errors
}
}
}
}
async function executeCommand(commandName: string): Promise<void> {
process.stdout.write(`\n=== Executing command: /${commandName} ===\n`);
// For command execution, we need a proper input stream
const input = new Pushable<SDKUserMessage>();
const claudeQuery = query({
prompt: input,
options: {
cwd: process.cwd(),
permissionMode: "plan",
includePartialMessages: false,
settingSources: ["user", "project"],
},
});
try {
// Push the command as a user message with / prefix
const userMessage: SDKUserMessage = {
type: "user",
message: {
role: "user",
content: `/${commandName}`,
},
parent_tool_use_id: null,
session_id: "",
};
input.push(userMessage);
// Iterate the query to process the command
let gotSystemInit = false;
for await (const message of claudeQuery) {
process.stdout.write(
` [${message.type}] ${message.type === "system" ? message.subtype : ""}\n`,
);
if (message.type === "system" && message.subtype === "init") {
gotSystemInit = true;
process.stdout.write(` Session: ${message.session_id}\n`);
process.stdout.write(` Model: ${message.model}\n`);
}
if (message.type === "assistant") {
const content = message.message?.content;
if (Array.isArray(content)) {
for (const block of content) {
if (block.type === "text") {
process.stdout.write(
` Response: ${block.text.slice(0, 200)}${block.text.length > 200 ? "..." : ""}\n`,
);
}
}
}
}
if (message.type === "result") {
process.stdout.write(` Result: ${message.subtype}\n`);
break;
}
}
} finally {
input.end();
await claudeQuery.return?.();
}
}
async function main() {
process.stdout.write("=== Claude Agent SDK Commands POC ===\n\n");
// PART 1: List available commands using supportedCommands()
process.stdout.write("=== Part 1: List Available Commands ===\n\n");
try {
const commands = await listAvailableCommands();
process.stdout.write(`Found ${commands.length} commands:\n\n`);
commands.forEach((cmd, index) => {
process.stdout.write(` ${index + 1}. /${cmd.name}\n`);
process.stdout.write(` Description: ${cmd.description}\n`);
if (cmd.argumentHint) {
process.stdout.write(` Arguments: ${cmd.argumentHint}\n`);
}
process.stdout.write("\n");
});
// PART 2: Demonstrate command execution (optional - uncomment to test)
// Commands are just prompts sent with / prefix
process.stdout.write("=== Part 2: Command Execution Explanation ===\n");
process.stdout.write("\n");
process.stdout.write("Commands are executed by sending them as prompts with / prefix.\n");
process.stdout.write("For example, to execute the 'help' command:\n");
process.stdout.write(' 1. Create a user message with content: "/help"\n');
process.stdout.write(" 2. Push it to the input stream\n");
process.stdout.write(" 3. Iterate the query to receive responses\n");
process.stdout.write("\n");
// Actually execute a command to demonstrate it works:
// Using "context" as it's fast and doesn't require arguments
await executeCommand("context");
} catch (error) {
process.stderr.write(`ERROR: ${error}\n`);
process.exit(1);
}
process.stdout.write("=== POC Complete ===\n");
}
main().catch((error) => {
process.stderr.write(`Fatal error: ${error}\n`);
process.exit(1);
});

View File

@@ -1,9 +0,0 @@
import type { ToolSet } from "ai";
/**
* Get all tools for voice LLM
* @param agentTools - Agent control tools from MCP
*/
export function getAllTools(agentTools?: ToolSet): ToolSet {
return agentTools ?? {};
}

View File

@@ -1,10 +0,0 @@
/**
* DEPRECATED: This file has been refactored.
*
* All orchestration logic has been moved to session.ts.
* Each Session now owns its own conversation state and manages
* the full lifecycle of user interactions.
*
* This file is kept as a stub to document the migration.
* It can be deleted once the refactoring is confirmed working.
*/

View File

@@ -1,173 +0,0 @@
#!/usr/bin/env npx tsx
/**
* Ad-hoc script to debug checkout_status_request timeouts.
*
* Usage:
* npx tsx packages/server/src/server/daemon-e2e/checkout-debug.ts [agentIdOrCwd1] [agentIdOrCwd2]
*
* To test against a different daemon:
* PASEO_LISTEN=127.0.0.1:7777 npx tsx packages/server/src/server/daemon-e2e/checkout-debug.ts
*/
import { WebSocket } from "ws";
import os from "node:os";
import { DaemonClient } from "../../client/daemon-client.js";
// Patch WebSocket to log all messages
const OriginalWebSocket = WebSocket;
class LoggingWebSocket extends OriginalWebSocket {
constructor(url: string, ...args: any[]) {
super(url, ...args);
console.log(`[WS] Connecting to ${url}`);
this.on("open", () => console.log("[WS] Connection opened"));
this.on("close", (code, reason) => console.log(`[WS] Connection closed: ${code} ${reason}`));
this.on("error", (err) => console.log(`[WS] Error: ${err}`));
this.on("message", (data) => {
const str = data.toString().slice(0, 200);
console.log(`[WS] Message received (${data.toString().length} bytes): ${str}...`);
});
}
}
const PASEO_HOME = process.env.PASEO_HOME ?? `${os.homedir()}/.paseo`;
const PASEO_LISTEN = process.env.PASEO_LISTEN ?? "127.0.0.1:6767";
const DAEMON_URL = `ws://${PASEO_LISTEN}/ws`;
const CLIENT_ID = "clsk_checkout_debug";
function requestCheckoutStatus(client: DaemonClient, cwd: string) {
return (client as any)[`get${"Checkout"}Status`](cwd);
}
async function testMultiAgentSequence() {
console.log("\n=== Testing multi-agent checkout sequence ===");
console.log(`Daemon URL: ${DAEMON_URL}`);
const client = new DaemonClient({
url: DAEMON_URL,
clientId: CLIENT_ID,
webSocketFactory: (url) => new LoggingWebSocket(url) as any,
reconnect: { enabled: false },
});
const agents: Array<{ id: string; title: string; cwd: string }> = [];
// Also log raw messages for debugging
client.on("checkout_status_response", (msg: any) => {
console.log(
`[RAW checkout_status_response] requestId=${msg.payload.requestId} cwd=${msg.payload.cwd}`,
);
});
// Listen to connection state changes
client.subscribeConnectionStatus((state) => {
console.log(`[Connection] status=${state.status}`);
});
try {
await client.connect();
console.log("Connected to daemon");
console.log(`Connection state: ${JSON.stringify(client.getConnectionState())}`);
console.log("Fetching agents...");
const agentsList = await client.fetchAgents();
agents.length = 0;
for (const a of agentsList) {
agents.push({ id: a.id, title: a.title ?? "(untitled)", cwd: a.cwd });
}
if (agents.length === 0) {
console.log("No agents found!");
return;
}
console.log("\nAvailable agents:");
for (const a of agents.slice(0, 10)) {
console.log(` - ${a.id.slice(0, 8)}... ${a.title}`);
}
if (agents.length > 10) {
console.log(` ... and ${agents.length - 10} more`);
}
// Pick first two agents (or use command line args)
const arg1 = process.argv[2];
const arg2 = process.argv[3];
const agent1 = (arg1 ? agents.find((a) => a.id === arg1) : null) ?? agents[0] ?? null;
const agent2 =
(arg2 ? agents.find((a) => a.id === arg2) : null) ?? agents[1] ?? agents[0] ?? null;
const cwd1 = arg1 && !agent1 ? arg1 : agent1?.cwd;
const cwd2 = arg2 && !agent2 ? arg2 : agent2?.cwd;
if (!cwd1) {
console.log("No checkout cwd available to test");
return;
}
console.log(`\n=== Test 1: Request checkout for cwd1 (${cwd1}) ===`);
const start1 = Date.now();
try {
const status1 = await requestCheckoutStatus(client, cwd1);
console.log(
`✓ Cwd1 completed in ${Date.now() - start1}ms - branch: ${status1.currentBranch}`,
);
} catch (err) {
console.log(`✗ Cwd1 failed after ${Date.now() - start1}ms:`, err);
}
if (cwd2) {
console.log(`\n=== Test 2: Request checkout for cwd2 (${cwd2}) ===`);
const start2 = Date.now();
try {
const status2 = await requestCheckoutStatus(client, cwd2);
console.log(
`✓ Cwd2 completed in ${Date.now() - start2}ms - branch: ${status2.currentBranch}`,
);
} catch (err) {
console.log(`✗ Cwd2 failed after ${Date.now() - start2}ms:`, err);
}
}
console.log(`\n=== Test 3: Request checkout for cwd1 again ===`);
const start3 = Date.now();
try {
const status3 = await requestCheckoutStatus(client, cwd1);
console.log(
`✓ Cwd1 (retry) completed in ${Date.now() - start3}ms - branch: ${status3.currentBranch}`,
);
} catch (err) {
console.log(`✗ Cwd1 (retry) failed after ${Date.now() - start3}ms:`, err);
}
if (cwd2) {
console.log(`\n=== Test 4: Request both cwds in parallel ===`);
const start4 = Date.now();
try {
const [p1, p2] = await Promise.all([
requestCheckoutStatus(client, cwd1),
requestCheckoutStatus(client, cwd2),
]);
console.log(`✓ Parallel completed in ${Date.now() - start4}ms`);
console.log(` Cwd1 branch: ${p1.currentBranch}`);
console.log(` Cwd2 branch: ${p2.currentBranch}`);
} catch (err) {
console.log(`✗ Parallel failed after ${Date.now() - start4}ms:`, err);
}
}
} catch (error) {
console.error("Test failed:", error);
} finally {
await client.close();
}
}
async function main() {
console.log("Checkout Debug Script - Multi-Agent Sequence Test");
console.log("==================================================");
console.log(`PASEO_HOME: ${PASEO_HOME}`);
await testMultiAgentSequence();
console.log("\n=== Done ===");
}
main().catch(console.error);

View File

@@ -1,48 +0,0 @@
import "dotenv/config";
import { beforeAll, afterAll } from "vitest";
import { mkdtempSync } from "fs";
import { tmpdir } from "os";
import path from "path";
import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js";
import { agentConfigs } from "./agent-configs.js";
// Re-export for backward compatibility - prefer using agentConfigs instead
export const CODEX_TEST_MODEL = agentConfigs.codex.model;
export const CODEX_TEST_THINKING_OPTION_ID = agentConfigs.codex.thinkingOptionId;
// Re-export agent configs
export {
agentConfigs,
getFullAccessConfig,
getAskModeConfig,
allProviders,
type AgentProvider,
type AgentTestConfig,
} from "./agent-configs.js";
export function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
}
// Shared daemon context for all e2e tests
let sharedCtx: DaemonTestContext | null = null;
export function getTestContext(): DaemonTestContext {
if (!sharedCtx) {
throw new Error("Test context not initialized. Did you call setupDaemonE2E()?");
}
return sharedCtx;
}
export function setupDaemonE2E(): void {
beforeAll(async () => {
sharedCtx = await createDaemonTestContext();
}, 30000);
afterAll(async () => {
if (sharedCtx) {
await sharedCtx.cleanup();
sharedCtx = null;
}
}, 60000);
}

View File

@@ -1,6 +0,0 @@
// Shared server types
export interface ServerConfig {
port: number;
isDev: boolean;
}

View File

@@ -1,161 +0,0 @@
import { randomUUID } from "node:crypto";
import { promises as fs } from "node:fs";
import path from "node:path";
import type { Logger } from "pino";
import {
type PersistedProjectRecord,
type PersistedWorkspaceRecord,
type ProjectRegistry,
type WorkspaceRegistry,
createPersistedProjectRecord,
createPersistedWorkspaceRecord,
} from "./workspace-registry.js";
type RegistryRecord = PersistedProjectRecord | PersistedWorkspaceRecord;
class FileBackedRegistry<TRecord extends RegistryRecord> {
private readonly filePath: string;
private readonly logger: Logger;
private readonly schema: (record: unknown) => TRecord;
private readonly getId: (record: TRecord) => string;
private loaded = false;
private readonly cache = new Map<string, TRecord>();
private persistQueue: Promise<void> = Promise.resolve();
constructor(options: {
filePath: string;
logger: Logger;
schema: (record: unknown) => TRecord;
getId: (record: TRecord) => string;
component: string;
}) {
this.filePath = options.filePath;
this.schema = options.schema;
this.getId = options.getId;
this.logger = options.logger.child({
module: "workspace-registry",
component: options.component,
});
}
async initialize(): Promise<void> {
await this.load();
}
async existsOnDisk(): Promise<boolean> {
try {
await fs.access(this.filePath);
return true;
} catch {
return false;
}
}
async list(): Promise<TRecord[]> {
await this.load();
return Array.from(this.cache.values());
}
async get(id: string): Promise<TRecord | null> {
await this.load();
return this.cache.get(id) ?? null;
}
async upsert(record: TRecord): Promise<void> {
await this.load();
const parsed = this.schema(record);
this.cache.set(this.getId(parsed), parsed);
await this.enqueuePersist();
}
async archive(id: string, archivedAt: string): Promise<void> {
await this.load();
const existing = this.cache.get(id);
if (!existing) {
return;
}
const next = this.schema({
...existing,
updatedAt: archivedAt,
archivedAt,
});
this.cache.set(id, next);
await this.enqueuePersist();
}
async remove(id: string): Promise<void> {
await this.load();
if (!this.cache.delete(id)) {
return;
}
await this.enqueuePersist();
}
private async load(): Promise<void> {
if (this.loaded) {
return;
}
this.cache.clear();
try {
const raw = await fs.readFile(this.filePath, "utf8");
const parsed = JSON.parse(raw) as TRecord[];
for (const record of parsed) {
const validated = this.schema(record);
this.cache.set(this.getId(validated), validated);
}
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== "ENOENT") {
this.logger.error({ err: error, filePath: this.filePath }, "Failed to load registry file");
}
}
this.loaded = true;
}
private async persist(): Promise<void> {
const records = Array.from(this.cache.values());
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
const tempPath = `${this.filePath}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
await fs.writeFile(tempPath, JSON.stringify(records, null, 2), "utf8");
await fs.rename(tempPath, this.filePath);
}
private async enqueuePersist(): Promise<void> {
const nextPersist = this.persistQueue.then(() => this.persist());
this.persistQueue = nextPersist.catch(() => {});
await nextPersist;
}
}
export class FileBackedProjectRegistry
extends FileBackedRegistry<PersistedProjectRecord>
implements ProjectRegistry
{
constructor(filePath: string, logger: Logger) {
super({
filePath,
logger,
schema: (record) => createPersistedProjectRecord(record as PersistedProjectRecord),
getId: (record) => record.projectId,
component: "projects",
});
}
}
export class FileBackedWorkspaceRegistry
extends FileBackedRegistry<PersistedWorkspaceRecord>
implements WorkspaceRegistry
{
constructor(filePath: string, logger: Logger) {
super({
filePath,
logger,
schema: (record) => createPersistedWorkspaceRecord(record as PersistedWorkspaceRecord),
getId: (record) => record.workspaceId,
component: "workspaces",
});
}
}

File diff suppressed because it is too large Load Diff