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": [
|
"entry": [
|
||||||
"index.ts",
|
"index.ts",
|
||||||
"app.config.js",
|
"app.config.js",
|
||||||
|
"babel.config.js",
|
||||||
"app/**/*.{ts,tsx}",
|
"app/**/*.{ts,tsx}",
|
||||||
"src/**/*.test.{ts,tsx}",
|
"src/**/*.test.{ts,tsx}",
|
||||||
"src/**/*.e2e.{ts,tsx}",
|
"src/**/*.e2e.{ts,tsx}",
|
||||||
|
"src/**/*.native.{ts,tsx}",
|
||||||
"e2e/**/*.{ts,tsx}",
|
"e2e/**/*.{ts,tsx}",
|
||||||
"playwright.config.{ts,js}",
|
"playwright.config.{ts,js}",
|
||||||
"vitest.config.{ts,js}"
|
"vitest.config.{ts,js}",
|
||||||
|
"test-stubs/**/*.ts"
|
||||||
],
|
],
|
||||||
"project": ["**/*.{ts,tsx,js,jsx}"],
|
"project": ["**/*.{ts,tsx,js,jsx}"],
|
||||||
"ignore": ["android/**", "ios/**", ".expo/**", "dist/**", "scripts/reset-project.js"]
|
"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
@@ -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