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:
@@ -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],
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user