From 3fba62b316ac96a9ae7f93531f05a78f80069586 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20G=C3=B3mez?= Date: Thu, 18 Dec 2025 15:29:01 +0100 Subject: [PATCH] ui: simpler streams --- TODO.md | 28 ++- package-lock.json | 75 ++++++ package.json | 1 + src/components/LayoutControls.tsx | 164 ++++--------- src/components/LayoutViewer.tsx | 216 ------------------ src/components/WindowRenderer.tsx | 11 - .../kinds/LiveActivityDetailRenderer.tsx | 79 +++---- .../nostr/kinds/LiveActivityRenderer.tsx | 2 +- src/components/ui/slider.tsx | 26 +++ src/core/logic.ts | 31 +-- src/core/state.ts | 6 - src/lib/layout-parser.ts | 39 ---- src/lib/layout-presets.test.ts | 123 +--------- src/lib/layout-presets.ts | 31 +-- src/types/app.ts | 3 +- src/types/man.ts | 27 --- 16 files changed, 223 insertions(+), 639 deletions(-) delete mode 100644 src/components/LayoutViewer.tsx create mode 100644 src/components/ui/slider.tsx delete mode 100644 src/lib/layout-parser.ts diff --git a/TODO.md b/TODO.md index 0078fd6..9ded46e 100644 --- a/TODO.md +++ b/TODO.md @@ -116,20 +116,34 @@ All event renderers now protected with error boundaries: ### Layout System Enhancements **Completed**: 2024-12-18 -**Files**: `src/lib/layout-presets.ts`, `src/components/LayoutControls.tsx`, `src/components/TabBar.tsx`, `src/core/logic.ts`, `src/core/state.ts` +**Files**: `src/lib/layout-presets.ts`, `src/components/LayoutControls.tsx`, `src/components/TabBar.tsx` Quick-win improvements to window management: -- **Balance Splits**: New action to equalize all split percentages to 50/50 after manual resizing - - Recursive tree traversal preserves window IDs and directions - - Added to Actions section in LayoutControls dropdown - - Smooth animation on balance operation -- **Keyboard Workspace Switching**: Cmd+1-9 (or Ctrl+1-9) to instantly switch to workspace by number +- **Keyboard Workspace Switching**: Cmd+1-9 (or Ctrl+1-9) to instantly switch to workspace by position - Browser-safe shortcuts (prevents default browser behavior) + - Switches by visual position in tab bar, not workspace number - Significantly faster workflow for power users -- Comprehensive test coverage for balanceLayout function +- **Adaptive Layout Presets**: All presets now handle any number of windows + - Grid layout adapts to any N ≥ 2 windows (2×2, 2×3, 3×3, etc.) + - Side-by-side handles 2-4 windows with equal splits + - Main+sidebar naturally adapts to any number +- Comprehensive test coverage for grid layouts with odd numbers ## Window Management Improvements +### Balance Splits +**Priority**: Medium | **Effort**: Low (1 hour) +**Description**: Action to equalize all split percentages to 50/50 after manual resizing +**Implementation**: +- Recursive tree traversal that preserves window IDs and directions +- Reset all `splitPercentage` values to 50 +- Add to layout dropdown (once basic features are validated) +- Smooth animation on balance operation + +**Use Case**: After manually resizing windows, quickly restore clean 50/50 proportions + +**Note**: Deferred until core preset/insertion features are validated in real usage + ### Fullscreen Mode **Priority**: High | **Effort**: Medium (2-3 hours) **Description**: Toggle window to fill entire workspace with minimal chrome diff --git a/package-lock.json b/package-lock.json index 732e229..6e5f74b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-slider": "^1.3.6", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tooltip": "^1.2.8", "applesauce-accounts": "^4.1.0", @@ -2949,6 +2950,80 @@ } } }, + "node_modules/@radix-ui/react-slider": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz", + "integrity": "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-slot": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", diff --git a/package.json b/package.json index 3183fb7..a094598 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-slider": "^1.3.6", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tooltip": "^1.2.8", "applesauce-accounts": "^4.1.0", diff --git a/src/components/LayoutControls.tsx b/src/components/LayoutControls.tsx index e1113e1..e6f7a19 100644 --- a/src/components/LayoutControls.tsx +++ b/src/components/LayoutControls.tsx @@ -6,9 +6,9 @@ import { Sparkles, SplitSquareHorizontal, SplitSquareVertical, - Scale, } from "lucide-react"; import { Button } from "./ui/button"; +import { Slider } from "./ui/slider"; import { useGrimoire } from "@/core/state"; import { cn } from "@/lib/utils"; import { getAllPresets } from "@/lib/layout-presets"; @@ -21,12 +21,17 @@ import { } from "./ui/dropdown-menu"; import { toast } from "sonner"; import type { LayoutConfig } from "@/types/app"; +import { useState } from "react"; export function LayoutControls() { - const { state, applyPresetLayout, balanceLayout, updateLayoutConfig } = - useGrimoire(); + const { state, applyPresetLayout, updateLayoutConfig } = useGrimoire(); const { workspaces, activeWorkspaceId, layoutConfig } = state; + // Local state for immediate slider feedback (debounced persistence) + const [localSplitPercentage, setLocalSplitPercentage] = useState< + number | null + >(null); + const activeWorkspace = workspaces[activeWorkspaceId]; const windowCount = activeWorkspace?.windowIds.length || 0; const presets = getAllPresets(); @@ -71,13 +76,13 @@ export function LayoutControls() { const getPresetIcon = (presetId: string) => { switch (presetId) { case "side-by-side": - return ; + return ; case "main-sidebar": - return ; + return ; case "grid": - return ; + return ; default: - return ; + return ; } }; @@ -91,33 +96,9 @@ export function LayoutControls() { { id: "column", label: "Vertical", icon: SplitSquareVertical }, ]; - const handleSplitChange = (increment: number) => { - const newValue = Math.max( - 20, - Math.min(80, layoutConfig.splitPercentage + increment) - ); - updateLayoutConfig({ splitPercentage: newValue }); - }; - - const handleBalance = () => { - try { - // Enable animations for smooth transition - document.body.classList.add("animating-layout"); - - balanceLayout(); - - // Remove animation class after transition completes - setTimeout(() => { - document.body.classList.remove("animating-layout"); - }, 180); - } catch (error) { - document.body.classList.remove("animating-layout"); - toast.error(`Failed to balance layout`, { - description: - error instanceof Error ? error.message : "Unknown error occurred", - }); - } - }; + // Current split percentage (local state during drag, global state otherwise) + const displayedSplitPercentage = + localSplitPercentage ?? layoutConfig.splitPercentage; return ( @@ -128,29 +109,16 @@ export function LayoutControls() { className="h-6 w-6" aria-label="Layout settings" > - + - {/* Presets Section */} + {/* Layouts Section */}
- Presets + Layouts
{presets.map((preset) => { - const hasMin = windowCount >= preset.minSlots; - const hasMax = !preset.maxSlots || windowCount <= preset.maxSlots; - const canApply = hasMin && hasMax; - - let statusText = ""; - if (!hasMin) { - statusText = `Needs ${preset.minSlots}+ (have ${windowCount})`; - } else if (!hasMax) { - statusText = `Max ${preset.maxSlots} (have ${windowCount})`; - } else if (preset.maxSlots) { - statusText = `${preset.minSlots}-${preset.maxSlots} windows`; - } else { - statusText = `${preset.minSlots}+ windows`; - } + const canApply = windowCount >= preset.minSlots; return ( {getPresetIcon(preset.id)}
{preset.name}
-
- {statusText} -
); @@ -172,9 +137,12 @@ export function LayoutControls() { - {/* Insertion Mode Section */} -
- Insert Mode + {/* Placement Section */} +
+
+ Placement +
+
Window insertion
{insertionModes.map((mode) => { const Icon = mode.icon; @@ -185,7 +153,7 @@ export function LayoutControls() { onClick={() => updateLayoutConfig({ insertionMode: mode.id })} className="flex items-center gap-2 cursor-pointer" > - + {mode.label} {isActive && (
@@ -198,64 +166,32 @@ export function LayoutControls() { {/* Split Ratio Section */}
-
- Split - - {layoutConfig.splitPercentage}/ - {100 - layoutConfig.splitPercentage} - -
-
- - - updateLayoutConfig({ - splitPercentage: Number(e.target.value), - }) - } - className="flex-1 h-1.5 bg-muted rounded-lg appearance-none cursor-pointer accent-accent" - /> - +
+
+ + Split Ratio + + + {displayedSplitPercentage}/{100 - displayedSplitPercentage} + +
+
+ Default split for new windows +
+ setLocalSplitPercentage(value)} + onValueCommit={([value]) => { + updateLayoutConfig({ splitPercentage: value }); + setLocalSplitPercentage(null); // Clear local state after persist + }} + min={20} + max={80} + step={1} + className="w-full" + />
- - - - {/* Actions Section */} -
- Actions -
- - - Balance Splits - {windowCount < 2 && ( - - Need 2+ windows - - )} - ); diff --git a/src/components/LayoutViewer.tsx b/src/components/LayoutViewer.tsx deleted file mode 100644 index 84d095e..0000000 --- a/src/components/LayoutViewer.tsx +++ /dev/null @@ -1,216 +0,0 @@ -import { useState } from "react"; -import { useGrimoire } from "@/core/state"; -import { getAllPresets } from "@/lib/layout-presets"; -import type { LayoutPreset } from "@/lib/layout-presets"; -import { Button } from "./ui/button"; -import { Grid2X2, Columns2, Split, CheckCircle2, AlertCircle } from "lucide-react"; -import { toast } from "sonner"; - -interface LayoutViewerProps { - presetId?: string; - error?: string; -} - -/** - * LAYOUT viewer - displays available layout presets and allows applying them - */ -export function LayoutViewer({ presetId, error }: LayoutViewerProps) { - const { state, applyPresetLayout } = useGrimoire(); - const activeWorkspace = state.workspaces[state.activeWorkspaceId]; - const windowCount = activeWorkspace.windowIds.length; - const presets = getAllPresets(); - const [applying, setApplying] = useState(null); - - // If a preset was specified via command line, show error or success - const specifiedPreset = presetId - ? presets.find((p) => p.id === presetId) - : null; - - const handleApplyPreset = async (preset: LayoutPreset) => { - if (windowCount < preset.slots) { - toast.error(`Not enough windows`, { - description: `Preset "${preset.name}" requires ${preset.slots} windows, but only ${windowCount} available.`, - }); - return; - } - - setApplying(preset.id); - try { - applyPresetLayout(preset); - toast.success(`Layout applied`, { - description: `Applied "${preset.name}" preset to workspace ${activeWorkspace.number}`, - }); - } catch (error) { - toast.error(`Failed to apply layout`, { - description: - error instanceof Error ? error.message : "Unknown error occurred", - }); - } finally { - setApplying(null); - } - }; - - const getPresetIcon = (presetId: string) => { - switch (presetId) { - case "side-by-side": - return ; - case "main-sidebar": - return ; - case "grid": - return ; - default: - return ; - } - }; - - const getPresetDiagram = (preset: LayoutPreset) => { - // Visual representation of the layout - switch (preset.id) { - case "side-by-side": - return ( -
-
-
-
- ); - case "main-sidebar": - return ( -
-
-
-
- ); - case "grid": - return ( -
-
-
-
-
-
- ); - default: - return null; - } - }; - - return ( -
-
- {/* Header */} -
-

Layout Presets

-

- Apply preset layouts to reorganize windows in workspace{" "} - {activeWorkspace.number} -

-
- Current windows: - {windowCount} -
-
- - {/* Command-line specified preset with error */} - {error && ( -
- -
-
- Command Error -
-
{error}
-
-
- )} - - {/* Command-line specified preset (valid) */} - {specifiedPreset && !error && ( -
- -
-
- Preset: {specifiedPreset.name} -
-
- {specifiedPreset.description} -
- {windowCount < specifiedPreset.slots ? ( -
- ⚠️ Not enough windows (requires {specifiedPreset.slots}, have{" "} - {windowCount}) -
- ) : ( - - )} -
-
- )} - - {/* Preset Gallery */} -
- {presets.map((preset) => { - const canApply = windowCount >= preset.slots; - const isApplying = applying === preset.id; - - return ( -
- {/* Icon and Title */} -
-
- {getPresetIcon(preset.id)} -
-
-
{preset.name}
-
- {preset.slots} windows -
-
-
- - {/* Description */} -

- {preset.description} -

- - {/* Visual Diagram */} -
{getPresetDiagram(preset)}
- - {/* Apply Button */} - {canApply ? ( - - ) : ( -
- Requires {preset.slots} windows (have {windowCount}) -
- )} -
- ); - })} -
-
-
- ); -} diff --git a/src/components/WindowRenderer.tsx b/src/components/WindowRenderer.tsx index 19ce20e..91ab763 100644 --- a/src/components/WindowRenderer.tsx +++ b/src/components/WindowRenderer.tsx @@ -27,9 +27,6 @@ const DebugViewer = lazy(() => import("./DebugViewer").then((m) => ({ default: m.DebugViewer })), ); const ConnViewer = lazy(() => import("./ConnViewer")); -const LayoutViewer = lazy(() => - import("./LayoutViewer").then((m) => ({ default: m.LayoutViewer })), -); // Loading fallback component function ViewerLoading() { @@ -165,14 +162,6 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) { case "conn": content = ; break; - case "layout": - content = ( - - ); - break; default: content = (
diff --git a/src/components/nostr/kinds/LiveActivityDetailRenderer.tsx b/src/components/nostr/kinds/LiveActivityDetailRenderer.tsx index bb3003c..bc9a8e9 100644 --- a/src/components/nostr/kinds/LiveActivityDetailRenderer.tsx +++ b/src/components/nostr/kinds/LiveActivityDetailRenderer.tsx @@ -6,12 +6,10 @@ import { getLiveHost, } from "@/lib/live-activity"; import { VideoPlayer } from "@/components/live/VideoPlayer"; -import { ChatView } from "@/components/nostr/ChatView"; import { StatusBadge } from "@/components/live/StatusBadge"; import { UserName } from "../UserName"; +import { Label } from "@/components/ui/Label"; import { Calendar } from "lucide-react"; -import { useOutboxRelays } from "@/hooks/useOutboxRelays"; -import { useLiveTimeline } from "@/hooks/useLiveTimeline"; interface LiveActivityDetailRendererProps { event: NostrEvent; @@ -24,36 +22,6 @@ export function LiveActivityDetailRenderer({ const status = useMemo(() => getLiveStatus(event), [event]); const hostPubkey = useMemo(() => getLiveHost(event), [event]); - // Get host's relay list for chat - const { relays: hostRelays } = useOutboxRelays({ - authors: [hostPubkey], - }); - - // Combine stream relays + host relays for chat events - const allRelays = useMemo( - () => Array.from(new Set([...activity.relays, ...hostRelays])), - [activity.relays, hostRelays], - ); - - // Fetch chat messages (kind 1311) and zaps (kind 9735) that a-tag this stream - const timelineFilter = useMemo( - () => ({ - kinds: [1311, 9735], - "#a": [ - `${event.kind}:${event.pubkey}:${event.tags.find((t) => t[0] === "d")?.[1] || ""}`, - ], - limit: 100, - }), - [event], - ); - - const { events: chatEvents } = useLiveTimeline( - `stream-feed-${event.id}`, - timelineFilter, - allRelays, - { stream: true }, - ); - const videoUrl = status === "live" && activity.streaming ? activity.streaming @@ -62,7 +30,7 @@ export function LiveActivityDetailRenderer({ : null; return ( -
+
{/* Video Section */}
{videoUrl ? ( @@ -94,27 +62,48 @@ export function LiveActivityDetailRenderer({ ) : (
- +

No stream available

)}
- {/* Compact title bar */} -
-

- {activity.title || "Untitled Live Activity"} -

+ {/* Stream Info Section */} +
+ {/* Title and Status Badge */} +
+

+ {activity.title || "Untitled Live Activity"} +

+ +
+ + {/* Host */} -
- {/* Chat Section */} -
- + {/* Description */} + {activity.summary && ( +

+ {activity.summary} +

+ )} + + {/* Hashtags */} + {activity.hashtags.filter((t) => !t.includes(":")).length > 0 && ( +
+ {activity.hashtags + .filter((t) => !t.includes(":")) + .map((tag) => ( + + ))} +
+ )}
); diff --git a/src/components/nostr/kinds/LiveActivityRenderer.tsx b/src/components/nostr/kinds/LiveActivityRenderer.tsx index e4d0b4a..bd6ef6d 100644 --- a/src/components/nostr/kinds/LiveActivityRenderer.tsx +++ b/src/components/nostr/kinds/LiveActivityRenderer.tsx @@ -112,7 +112,7 @@ export function LiveActivityRenderer({ event }: LiveActivityRendererProps) {
{/* Hashtags */} {activity.hashtags - .filter((t) => !t.startsWith("internal:")) + .filter((t) => !t.includes(":")) .map((tag) => (