mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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:
@@ -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"]
|
||||
|
||||
@@ -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"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
@@ -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],
|
||||
},
|
||||
}));
|
||||
@@ -1,2 +0,0 @@
|
||||
export const AUDIO_DEBUG_ENABLED =
|
||||
typeof process !== "undefined" && process.env?.EXPO_PUBLIC_ENABLE_AUDIO_DEBUG === "1";
|
||||
1
packages/app/src/hooks/use-dictation.d.ts
vendored
1
packages/app/src/hooks/use-dictation.d.ts
vendored
@@ -1 +0,0 @@
|
||||
export * from "./use-dictation";
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 ?? {};
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
// Shared server types
|
||||
|
||||
export interface ServerConfig {
|
||||
port: number;
|
||||
isDev: boolean;
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user