ui: simpler streams

This commit is contained in:
Alejandro Gómez
2025-12-18 15:29:01 +01:00
parent b57ff31907
commit 3fba62b316
16 changed files with 223 additions and 639 deletions

28
TODO.md
View File

@@ -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

75
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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 <Columns2 className="h-4 w-4" />;
return <Columns2 className="h-4 w-4 text-muted-foreground" />;
case "main-sidebar":
return <Split className="h-4 w-4" />;
return <Split className="h-4 w-4 text-muted-foreground" />;
case "grid":
return <Grid2X2 className="h-4 w-4" />;
return <Grid2X2 className="h-4 w-4 text-muted-foreground" />;
default:
return <Grid2X2 className="h-4 w-4" />;
return <Grid2X2 className="h-4 w-4 text-muted-foreground" />;
}
};
@@ -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 (
<DropdownMenu>
@@ -128,29 +109,16 @@ export function LayoutControls() {
className="h-6 w-6"
aria-label="Layout settings"
>
<SlidersHorizontal className="h-3 w-3" />
<SlidersHorizontal className="h-3 w-3 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
{/* Presets Section */}
{/* Layouts Section */}
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
Presets
Layouts
</div>
{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 (
<DropdownMenuItem
@@ -162,9 +130,6 @@ export function LayoutControls() {
<div className="flex-shrink-0">{getPresetIcon(preset.id)}</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-sm">{preset.name}</div>
<div className="text-xs text-muted-foreground truncate">
{statusText}
</div>
</div>
</DropdownMenuItem>
);
@@ -172,9 +137,12 @@ export function LayoutControls() {
<DropdownMenuSeparator />
{/* Insertion Mode Section */}
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
Insert Mode
{/* Placement Section */}
<div className="px-2 py-1.5 space-y-0.5">
<div className="text-xs font-semibold text-muted-foreground">
Placement
</div>
<div className="text-xs text-muted-foreground">Window insertion</div>
</div>
{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"
>
<Icon className="h-3.5 w-3.5" />
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
<span className="flex-1">{mode.label}</span>
{isActive && (
<div className="h-1.5 w-1.5 rounded-full bg-accent" />
@@ -198,64 +166,32 @@ export function LayoutControls() {
{/* Split Ratio Section */}
<div className="px-2 py-2 space-y-2">
<div className="flex items-center justify-between text-xs">
<span className="font-semibold text-muted-foreground">Split</span>
<span className="text-foreground">
{layoutConfig.splitPercentage}/
{100 - layoutConfig.splitPercentage}
</span>
</div>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
className="h-6 px-2 text-xs"
onClick={() => handleSplitChange(-10)}
>
-
</Button>
<input
type="range"
min="20"
max="80"
value={layoutConfig.splitPercentage}
onChange={(e) =>
updateLayoutConfig({
splitPercentage: Number(e.target.value),
})
}
className="flex-1 h-1.5 bg-muted rounded-lg appearance-none cursor-pointer accent-accent"
/>
<Button
variant="outline"
size="sm"
className="h-6 px-2 text-xs"
onClick={() => handleSplitChange(10)}
>
+
</Button>
<div className="space-y-0.5">
<div className="flex items-center justify-between text-xs">
<span className="font-semibold text-muted-foreground">
Split Ratio
</span>
<span className="text-foreground">
{displayedSplitPercentage}/{100 - displayedSplitPercentage}
</span>
</div>
<div className="text-xs text-muted-foreground">
Default split for new windows
</div>
</div>
<Slider
value={[displayedSplitPercentage]}
onValueChange={([value]) => setLocalSplitPercentage(value)}
onValueCommit={([value]) => {
updateLayoutConfig({ splitPercentage: value });
setLocalSplitPercentage(null); // Clear local state after persist
}}
min={20}
max={80}
step={1}
className="w-full"
/>
</div>
<DropdownMenuSeparator />
{/* Actions Section */}
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
Actions
</div>
<DropdownMenuItem
onClick={handleBalance}
disabled={windowCount < 2}
className="flex items-center gap-2 cursor-pointer"
>
<Scale className="h-3.5 w-3.5" />
<span className="flex-1">Balance Splits</span>
{windowCount < 2 && (
<span className="text-xs text-muted-foreground">
Need 2+ windows
</span>
)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);

View File

@@ -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<string | null>(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 <Columns2 className="h-8 w-8" />;
case "main-sidebar":
return <Split className="h-8 w-8" />;
case "grid":
return <Grid2X2 className="h-8 w-8" />;
default:
return <Grid2X2 className="h-8 w-8" />;
}
};
const getPresetDiagram = (preset: LayoutPreset) => {
// Visual representation of the layout
switch (preset.id) {
case "side-by-side":
return (
<div className="flex gap-2 h-16">
<div className="flex-1 border-2 border-muted-foreground/30 rounded bg-muted/20" />
<div className="flex-1 border-2 border-muted-foreground/30 rounded bg-muted/20" />
</div>
);
case "main-sidebar":
return (
<div className="flex gap-2 h-16">
<div className="flex-[7] border-2 border-muted-foreground/30 rounded bg-muted/20" />
<div className="flex-[3] border-2 border-muted-foreground/30 rounded bg-muted/20" />
</div>
);
case "grid":
return (
<div className="grid grid-cols-2 grid-rows-2 gap-2 h-16">
<div className="border-2 border-muted-foreground/30 rounded bg-muted/20" />
<div className="border-2 border-muted-foreground/30 rounded bg-muted/20" />
<div className="border-2 border-muted-foreground/30 rounded bg-muted/20" />
<div className="border-2 border-muted-foreground/30 rounded bg-muted/20" />
</div>
);
default:
return null;
}
};
return (
<div className="h-full w-full flex flex-col bg-background text-foreground">
<div className="flex-1 overflow-y-auto p-6">
{/* Header */}
<div className="mb-6">
<h1 className="text-2xl font-bold mb-2">Layout Presets</h1>
<p className="text-muted-foreground text-sm">
Apply preset layouts to reorganize windows in workspace{" "}
{activeWorkspace.number}
</p>
<div className="mt-2 text-sm">
<span className="text-muted-foreground">Current windows: </span>
<span className="font-semibold">{windowCount}</span>
</div>
</div>
{/* Command-line specified preset with error */}
{error && (
<div className="mb-6 p-4 rounded-lg bg-destructive/10 border border-destructive/50 flex items-start gap-3">
<AlertCircle className="h-5 w-5 text-destructive mt-0.5 flex-shrink-0" />
<div className="flex-1">
<div className="font-semibold text-destructive mb-1">
Command Error
</div>
<div className="text-sm text-muted-foreground">{error}</div>
</div>
</div>
)}
{/* Command-line specified preset (valid) */}
{specifiedPreset && !error && (
<div className="mb-6 p-4 rounded-lg bg-accent/10 border border-accent/50 flex items-start gap-3">
<CheckCircle2 className="h-5 w-5 text-accent-foreground mt-0.5 flex-shrink-0" />
<div className="flex-1">
<div className="font-semibold mb-1">
Preset: {specifiedPreset.name}
</div>
<div className="text-sm text-muted-foreground mb-3">
{specifiedPreset.description}
</div>
{windowCount < specifiedPreset.slots ? (
<div className="text-sm text-destructive">
Not enough windows (requires {specifiedPreset.slots}, have{" "}
{windowCount})
</div>
) : (
<Button
onClick={() => handleApplyPreset(specifiedPreset)}
size="sm"
disabled={applying === specifiedPreset.id}
>
{applying === specifiedPreset.id
? "Applying..."
: "Apply Preset"}
</Button>
)}
</div>
</div>
)}
{/* Preset Gallery */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{presets.map((preset) => {
const canApply = windowCount >= preset.slots;
const isApplying = applying === preset.id;
return (
<div
key={preset.id}
className={`p-4 rounded-lg border transition-colors ${
canApply
? "border-border hover:border-accent/50 hover:bg-accent/5"
: "border-muted-foreground/20 opacity-60"
}`}
>
{/* Icon and Title */}
<div className="flex items-center gap-3 mb-3">
<div className="text-muted-foreground">
{getPresetIcon(preset.id)}
</div>
<div>
<div className="font-semibold">{preset.name}</div>
<div className="text-xs text-muted-foreground">
{preset.slots} windows
</div>
</div>
</div>
{/* Description */}
<p className="text-sm text-muted-foreground mb-3">
{preset.description}
</p>
{/* Visual Diagram */}
<div className="mb-3">{getPresetDiagram(preset)}</div>
{/* Apply Button */}
{canApply ? (
<Button
onClick={() => handleApplyPreset(preset)}
className="w-full"
variant="outline"
size="sm"
disabled={isApplying}
>
{isApplying ? "Applying..." : "Apply"}
</Button>
) : (
<div className="text-xs text-muted-foreground text-center py-2">
Requires {preset.slots} windows (have {windowCount})
</div>
)}
</div>
);
})}
</div>
</div>
</div>
);
}

View File

@@ -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 = <ConnViewer />;
break;
case "layout":
content = (
<LayoutViewer
presetId={window.props.presetId}
error={window.props.error}
/>
);
break;
default:
content = (
<div className="p-4 text-muted-foreground">

View File

@@ -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 (
<div className="flex flex-col h-full bg-background">
<div className="flex flex-col h-full bg-background overflow-y-auto">
{/* Video Section */}
<div className="flex-shrink-0">
{videoUrl ? (
@@ -94,27 +62,48 @@ export function LiveActivityDetailRenderer({
) : (
<div className="aspect-video bg-neutral-800 flex items-center justify-center">
<div className="text-center text-neutral-400">
<StatusBadge status={status} size="md" />
<StatusBadge status={status} />
<p className="mt-4">No stream available</p>
</div>
</div>
)}
</div>
{/* Compact title bar */}
<div className="flex items-center justify-between gap-4">
<h1 className="text-lg font-bold flex-1 line-clamp-1">
{activity.title || "Untitled Live Activity"}
</h1>
{/* Stream Info Section */}
<div className="flex-1 p-2 space-y-3">
{/* Title and Status Badge */}
<div className="flex items-start justify-between gap-4">
<h1 className="text-2xl font-bold text-balance">
{activity.title || "Untitled Live Activity"}
</h1>
<StatusBadge status={status} />
</div>
{/* Host */}
<UserName
pubkey={hostPubkey}
className="text-sm font-semibold line-clamp-1"
className="text-sm text-accent"
/>
</div>
{/* Chat Section */}
<div className="flex-1 min-h-0">
<ChatView events={chatEvents} className="h-full" />
{/* Description */}
{activity.summary && (
<p className="text-base text-muted-foreground leading-relaxed">
{activity.summary}
</p>
)}
{/* Hashtags */}
{activity.hashtags.filter((t) => !t.includes(":")).length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
{activity.hashtags
.filter((t) => !t.includes(":"))
.map((tag) => (
<Label key={tag} size="sm">
{tag}
</Label>
))}
</div>
)}
</div>
</div>
);

View File

@@ -112,7 +112,7 @@ export function LiveActivityRenderer({ event }: LiveActivityRendererProps) {
<div className="flex items-center gap-2 text-xs flex-wrap">
{/* Hashtags */}
{activity.hashtags
.filter((t) => !t.startsWith("internal:"))
.filter((t) => !t.includes(":"))
.map((tag) => (
<Label key={tag} size="sm">
{tag}

View File

@@ -0,0 +1,26 @@
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import { cn } from "@/lib/utils"
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
"relative flex w-full touch-none select-none items-center",
className
)}
{...props}
>
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
))
Slider.displayName = SliderPrimitive.Root.displayName
export { Slider }

View File

@@ -2,11 +2,7 @@ import { v4 as uuidv4 } from "uuid";
import type { MosaicNode } from "react-mosaic-component";
import { GrimoireState, WindowInstance, UserRelays } from "@/types/app";
import { insertWindow } from "@/lib/layout-utils";
import {
applyPresetToLayout,
balanceLayout,
type LayoutPreset,
} from "@/lib/layout-presets";
import { applyPresetToLayout, type LayoutPreset } from "@/lib/layout-presets";
/**
* Finds the lowest available workspace number.
@@ -398,28 +394,3 @@ export const applyPresetLayout = (
return state;
}
};
/**
* Balances all split percentages in the active workspace to 50/50.
* Useful for equalizing splits after manual resizing.
*/
export const balanceLayoutInWorkspace = (
state: GrimoireState,
): GrimoireState => {
const activeId = state.activeWorkspaceId;
const ws = state.workspaces[activeId];
// Balance the layout tree
const balancedLayout = balanceLayout(ws.layout);
return {
...state,
workspaces: {
...state.workspaces,
[activeId]: {
...ws,
layout: balancedLayout,
},
},
};
};

View File

@@ -241,11 +241,6 @@ export const useGrimoire = () => {
[setState],
);
const balanceLayout = useCallback(
() => setState((prev) => Logic.balanceLayoutInWorkspace(prev)),
[setState],
);
return {
state,
locale: state.locale || browserLocale,
@@ -261,6 +256,5 @@ export const useGrimoire = () => {
setActiveAccountRelays,
updateLayoutConfig,
applyPresetLayout,
balanceLayout,
};
};

View File

@@ -1,39 +0,0 @@
import { getPreset, getAllPresets } from "./layout-presets";
export interface LayoutCommandResult {
/** The preset ID to apply, or undefined to show preset list */
presetId?: string;
/** Error message if parsing failed */
error?: string;
}
/**
* Parses the /layout command arguments
*
* Usage:
* /layout - Show all available presets
* /layout side-by-side - Apply the side-by-side preset
* /layout grid - Apply the grid preset
*/
export function parseLayoutCommand(args: string[]): LayoutCommandResult {
// No arguments - show preset list
if (args.length === 0) {
return {};
}
// Get the preset ID (first argument)
const presetId = args[0].toLowerCase();
// Validate preset exists
const preset = getPreset(presetId);
if (!preset) {
const availablePresets = getAllPresets()
.map((p) => p.id)
.join(", ");
return {
error: `Unknown preset "${presetId}". Available presets: ${availablePresets}`,
};
}
return { presetId };
}

View File

@@ -2,7 +2,6 @@ import { describe, it, expect } from "vitest";
import {
collectWindowIds,
applyPresetToLayout,
balanceLayout,
BUILT_IN_PRESETS,
} from "./layout-presets";
import type { MosaicNode } from "react-mosaic-component";
@@ -151,16 +150,23 @@ describe("layout-presets", () => {
expect(windowIds).toEqual(["w1", "w2", "w3"]);
});
it("handles 4 windows (max allowed)", () => {
it("handles 4 windows", () => {
const layout = sideBySidePreset.generate(["w1", "w2", "w3", "w4"]);
const windowIds = collectWindowIds(layout);
expect(windowIds).toEqual(["w1", "w2", "w3", "w4"]);
});
it("throws error for 5+ windows", () => {
expect(() =>
sideBySidePreset.generate(["w1", "w2", "w3", "w4", "w5"])
).toThrow("maximum 4 windows");
it("handles 5 windows", () => {
const layout = sideBySidePreset.generate(["w1", "w2", "w3", "w4", "w5"]);
const windowIds = collectWindowIds(layout);
expect(windowIds).toEqual(["w1", "w2", "w3", "w4", "w5"]);
});
it("handles 8 windows (many splits)", () => {
const windows = Array.from({ length: 8 }, (_, i) => `w${i + 1}`);
const layout = sideBySidePreset.generate(windows);
const windowIds = collectWindowIds(layout);
expect(windowIds).toEqual(windows);
});
});
@@ -194,84 +200,6 @@ describe("layout-presets", () => {
});
});
describe("balanceLayout", () => {
it("returns null for null layout", () => {
expect(balanceLayout(null)).toBeNull();
});
it("returns single window unchanged", () => {
expect(balanceLayout("w1")).toBe("w1");
});
it("balances a simple binary split", () => {
const unbalanced: MosaicNode<string> = {
direction: "row",
first: "w1",
second: "w2",
splitPercentage: 70,
};
const balanced = balanceLayout(unbalanced);
expect(balanced).toEqual({
direction: "row",
first: "w1",
second: "w2",
splitPercentage: 50,
});
});
it("balances nested splits recursively", () => {
const unbalanced: MosaicNode<string> = {
direction: "row",
first: {
direction: "column",
first: "w1",
second: "w2",
splitPercentage: 30,
},
second: {
direction: "column",
first: "w3",
second: "w4",
splitPercentage: 80,
},
splitPercentage: 60,
};
const balanced = balanceLayout(unbalanced);
// All splits should be 50%
expect(balanced).toMatchObject({
splitPercentage: 50,
first: { splitPercentage: 50 },
second: { splitPercentage: 50 },
});
});
it("preserves window IDs and directions", () => {
const original: MosaicNode<string> = {
direction: "column",
first: "w1",
second: {
direction: "row",
first: "w2",
second: "w3",
splitPercentage: 75,
},
splitPercentage: 25,
};
const balanced = balanceLayout(original);
const windowIds = collectWindowIds(balanced);
expect(windowIds).toEqual(["w1", "w2", "w3"]);
// Check directions preserved
if (balanced && typeof balanced !== "string") {
expect(balanced.direction).toBe("column");
if (typeof balanced.second !== "string") {
expect(balanced.second.direction).toBe("row");
}
}
});
});
describe("applyPresetToLayout", () => {
it("throws error if too few windows", () => {
const layout: MosaicNode<string> = "w1";
@@ -280,33 +208,6 @@ describe("layout-presets", () => {
).toThrow("at least 2 windows");
});
it("throws error if too many windows for side-by-side", () => {
const layout: MosaicNode<string> = {
direction: "row",
first: {
direction: "row",
first: "w1",
second: "w2",
splitPercentage: 50,
},
second: {
direction: "row",
first: {
direction: "row",
first: "w3",
second: "w4",
splitPercentage: 50,
},
second: "w5",
splitPercentage: 50,
},
splitPercentage: 50,
};
expect(() =>
applyPresetToLayout(layout, BUILT_IN_PRESETS["side-by-side"])
).toThrow("maximum 4 windows");
});
it("applies grid preset to existing layout", () => {
const existingLayout: MosaicNode<string> = {
direction: "row",

View File

@@ -114,13 +114,9 @@ export const BUILT_IN_PRESETS: Record<string, LayoutPreset> = {
"side-by-side": {
id: "side-by-side",
name: "Side by Side",
description: "All windows in a single row (max 4)",
description: "All windows in a single row",
minSlots: 2,
maxSlots: 4,
generate: (windowIds: string[]) => {
if (windowIds.length > 4) {
throw new Error("Side-by-side layout supports maximum 4 windows");
}
return buildHorizontalRow(windowIds);
},
},
@@ -203,31 +199,6 @@ export function applyPresetToLayout(
return preset.generate(windowIds);
}
/**
* Balances all split percentages in a layout tree to 50/50
* Useful for equalizing splits after manual resizing
*/
export function balanceLayout(
layout: MosaicNode<string> | null
): MosaicNode<string> | null {
if (layout === null) {
return null;
}
// Leaf node (window ID), return as-is
if (typeof layout === "string") {
return layout;
}
// Branch node, balance this split and recurse
return {
direction: layout.direction,
first: balanceLayout(layout.first),
second: balanceLayout(layout.second),
splitPercentage: 50,
};
}
/**
* Get a preset by ID
*/

View File

@@ -15,8 +15,7 @@ export type AppId =
| "decode"
| "relay"
| "debug"
| "conn"
| "layout";
| "conn";
export interface WindowInstance {
id: string;

View File

@@ -4,7 +4,6 @@ import type { AppId } from "./app";
import { parseOpenCommand } from "@/lib/open-parser";
import { parseProfileCommand } from "@/lib/profile-parser";
import { parseRelayCommand } from "@/lib/relay-parser";
import { parseLayoutCommand } from "@/lib/layout-parser";
import { resolveNip05Batch } from "@/lib/nip05";
export interface ManPageEntry {
@@ -458,30 +457,4 @@ export const manPages: Record<string, ManPageEntry> = {
category: "System",
defaultProps: {},
},
layout: {
name: "layout",
section: "1",
synopsis: "layout [preset-name]",
description:
"Apply a preset layout to reorganize windows in the current workspace. Presets provide common layout arrangements like side-by-side splits, main+sidebar configurations, and grid layouts. Running without arguments shows all available presets.",
options: [
{
flag: "[preset-name]",
description: "Preset layout to apply (side-by-side, main-sidebar, grid)",
},
],
examples: [
"layout View all available presets",
"layout side-by-side Apply 50/50 horizontal split (2 windows)",
"layout main-sidebar Apply 70/30 horizontal split (2 windows)",
"layout grid Apply 2×2 grid layout (4 windows)",
],
seeAlso: ["man"],
appId: "layout",
category: "System",
argParser: (args: string[]) => {
const result = parseLayoutCommand(args);
return result;
},
},
};