Files
hikari-desktop/src/routes/+page.svelte
T
hikari f173892aaa
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m2s
CI / Build Linux (push) Has been cancelled
CI / Build Windows (cross-compile) (push) Has been cancelled
CI / Lint & Test (push) Has been cancelled
feat: major feature additions and improvements (#135)
## Summary

This PR includes major feature additions, bug fixes, comprehensive testing improvements, and responsive design enhancements!

## New Features โœจ

### Plugin & MCP Management (#133, #134)
- **Plugin Management Panel**: Install, uninstall, enable/disable, and update plugins
- **MCP Server Management Panel**: Add/remove MCP servers, view detailed configuration
- **Marketplace Management**: Add/remove plugin marketplaces from GitHub
- Backend commands for full CLI integration (`list_plugins`, `install_plugin`, `add_mcp_server`, etc.)
- Beautiful UI with proper loading states, error handling, and theme support

### Visual Todo List Panel (#132)
- Real-time todo list display when Hikari uses the `TodoWrite` tool
- Shows pending/in-progress/completed status with visual indicators
- Progress bar and completion count
- Automatically clears on disconnect
- Theme-aware styling

### Clear Session History Button (#130)
- "Clear All Sessions" button in Session History panel
- Confirmation dialog with session count
- Keyboard support and accessibility features
- Gives users control over disk usage

### CLI Version Display (#131)
- Displays Claude CLI version in status bar
- Auto-polls every 30 seconds for updates
- Useful for debugging and feature compatibility

## Bug Fixes ๐Ÿ›

### Stats Panel Scrolling (#136)
- **Fixed stats panel overflow**: Added scrollable container with `max-height` constraint
- Stats panel now scrolls when content (Tools Used, Historical Costs, Budget sections) gets too long
- Prevents content from overflowing off screen

### Agent Monitor Fixes (#122)
- **Fixed agents stuck in "running" state**: Added `SubagentStop` hook parsing
- **Fixed agents persisting after disconnect**: Call `clearConversation()` on disconnect
- **Fixed "Kill All" button**: Now properly marks all agents as errored
- **Fixed badge persisting after tab close**: Cleanup agents when conversation is deleted
- Comprehensive tests for agent lifecycle management

### Discord RPC Cleanup (#129)
- Removed file-based logging for Discord RPC
- Replaced with proper `tracing` framework usage
- Reduces disk usage and eliminates maintenance burden

### Close Modal Bug Fix (#128)
- Fixed close confirmation modal not triggering after Discord RPC refactor
- Removed frontend calls to deleted `log_discord_rpc` command
- Modal now works correctly after all operations

### Responsive Design Fixes (#118)
- Fixed top navigation icons getting cut off at small screen widths
- Fixed Connect button disappearing on narrow screens
- Fixed bottom status info (clock, CLI version) getting cut off
- Added flex-wrap and mobile-optimised layouts
- Icons-only mode on screens < 640px
- Vertical stacking on screens < 768px

## Testing Improvements ๐Ÿงช

### Comprehensive Test Coverage (#114)
- **417 backend tests** (up from 408)
- **387 frontend tests** (up from 363)
- **61%+ backend code coverage**
- Added E2E integration tests for cross-platform notification commands
- New test files: `agents.test.ts`, comprehensive CLI parsing tests
- Tests for `debug_logger.rs`, `bridge_manager.rs`, `notifications.rs`
- Console mocking for cleaner test output
- Fixed flaky frontend tests

### Testing Documentation
- Updated CLAUDE.md with comprehensive testing guidelines
- Documented mocking approaches (console mocking, E2E command structure testing)
- Added step-by-step guide for adding tests to new features
- Goal to maintain ~100% test coverage documented

## Closes

Closes #114
Closes #118
Closes #122
Closes #128
Closes #129
Closes #130
Closes #131
Closes #132
Closes #133
Closes #134
Closes #136

## Technical Details

- All new backend commands properly registered in `lib.rs`
- CLI output parsing with comprehensive test coverage
- Cross-platform compatibility verified through E2E tests (Linux CI can test Windows commands)
- Theme-aware UI components using CSS variables throughout
- Proper TypeScript types for all new stores and components
- ESLint and Prettier compliant
- All Clippy warnings addressed

โœจ This PR was created with help from Hikari~ ๐ŸŒธ

Reviewed-on: #135
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-02-07 21:15:41 -08:00

714 lines
22 KiB
Svelte

<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { get } from "svelte/store";
import {
initializeTauriListeners,
cleanupTauriListeners,
initializeDiscordRpc,
stopDiscordRpc,
updateDiscordRpc,
setSkipNextGreeting,
} from "$lib/tauri";
import { configStore, applyTheme, applyFontSize, isCompactMode } from "$lib/stores/config";
import { initNotificationSync, cleanupNotificationSync } from "$lib/stores/notifications";
import { conversationsStore } from "$lib/stores/conversations";
import { claudeStore, isClaudeProcessing } from "$lib/stores/claude";
import { editorStore } from "$lib/stores/editor";
import { getCurrentWindow, LogicalSize } from "@tauri-apps/api/window";
import "$lib/notifications/testNotifications";
import Terminal from "$lib/components/Terminal.svelte";
import InputBar from "$lib/components/InputBar.svelte";
import StatusBar from "$lib/components/StatusBar.svelte";
import AnimeGirl from "$lib/components/AnimeGirl.svelte";
import CompactMode from "$lib/components/CompactMode.svelte";
import EditorPanel from "$lib/components/editor/EditorPanel.svelte";
import { characterState } from "$lib/stores/character";
import type { CharacterState } from "$lib/types/states";
import PermissionModal from "$lib/components/PermissionModal.svelte";
import UserQuestionModal from "$lib/components/UserQuestionModal.svelte";
import ConfigSidebar from "$lib/components/ConfigSidebar.svelte";
import AchievementNotification from "$lib/components/AchievementNotification.svelte";
import AchievementsPanel from "$lib/components/AchievementsPanel.svelte";
import UpdateNotification from "$lib/components/UpdateNotification.svelte";
import CloseAppConfirmModal from "$lib/components/CloseAppConfirmModal.svelte";
import MemoryBrowserPanel from "$lib/components/MemoryBrowserPanel.svelte";
import { debugConsoleStore } from "$lib/stores/debugConsole";
import { initializeTodoListener, cleanupTodoListener } from "$lib/stores/todos";
let initialized = false;
let updateNotification: UpdateNotification | undefined = $state(undefined);
let achievementPanelOpen = $state(false);
let currentCharacterState: CharacterState = $state("idle");
let compactModeActive = $state(false);
let closeConfirmModalOpen = $state(false);
let hasActiveConversation = $state(false);
// Editor state
const isEditorVisible = editorStore.isEditorVisible;
let lastInitializedCwd = "";
// Track connection status and CWD for the editor
const connectionStatus = claudeStore.connectionStatus;
const currentWorkingDirectory = claudeStore.currentWorkingDirectory;
// Initialize/update editor file tree when CWD changes while editor is visible
$effect(() => {
const visible = $isEditorVisible;
const cwd = $currentWorkingDirectory;
const connected = $connectionStatus === "connected";
// Only initialize when editor is visible, connected, and CWD is set
if (visible && connected && cwd && cwd !== lastInitializedCwd) {
lastInitializedCwd = cwd;
editorStore.initializeFileTree(cwd);
}
// Hide editor if disconnected
if (!connected && visible) {
editorStore.hideEditor();
}
});
// Get reactive references to conversation stores
const activeConversationId = conversationsStore.activeConversationId;
const conversations = conversationsStore.conversations;
// Update Discord RPC when active conversation or model changes
$effect(() => {
// Access stores directly (without get()) to create reactive dependencies
const activeId = $activeConversationId;
const convs = $conversations;
const activeConv = activeId ? convs.get(activeId) : null;
const config = configStore.getConfig();
const model = config.model || "claude";
if (activeConv && config.discord_rpc_enabled) {
updateDiscordRpc(activeConv.name, model, activeConv.startedAt);
}
});
// Window size constants
const COMPACT_WIDTH = 280;
const COMPACT_HEIGHT = 400;
// Store the previous window size to restore when exiting compact mode
let previousWindowSize: { width: number; height: number } | null = null;
characterState.subscribe((state) => {
currentCharacterState = state;
});
isCompactMode.subscribe((value) => {
compactModeActive = value;
});
function getPanelGlowClass(): string {
switch (currentCharacterState) {
case "thinking":
return "panel-glow-thinking";
case "typing":
return "panel-glow-typing";
case "searching":
return "panel-glow-searching";
case "coding":
return "panel-glow-coding";
case "mcp":
return "panel-glow-mcp";
case "success":
return "panel-glow-success";
case "error":
return "panel-glow-error";
default:
return "";
}
}
// Resizable panel state
let panelWidth = $state(320); // Default width in pixels
let isResizing = $state(false);
const MIN_PANEL_WIDTH = 200;
const MAX_PANEL_WIDTH = 600;
function startResize(event: MouseEvent) {
isResizing = true;
event.preventDefault();
document.addEventListener("mousemove", handleResize);
document.addEventListener("mouseup", stopResize);
}
function handleResize(event: MouseEvent) {
if (!isResizing) return;
const newWidth = event.clientX;
panelWidth = Math.max(MIN_PANEL_WIDTH, Math.min(MAX_PANEL_WIDTH, newWidth));
}
function stopResize() {
if (isResizing) {
isResizing = false;
document.removeEventListener("mousemove", handleResize);
document.removeEventListener("mouseup", stopResize);
// Save the panel width to config
configStore.updateConfig({ character_panel_width: panelWidth });
}
}
// Global keyboard shortcuts
function handleGlobalKeydown(event: KeyboardEvent) {
// Don't trigger shortcuts when typing in inputs (except for specific ones)
const target = event.target as HTMLElement;
const isInputFocused = target.tagName === "INPUT" || target.tagName === "TEXTAREA";
// Escape closes panels (always works)
if (event.key === "Escape") {
// Check if any panels are open and close them
if (achievementPanelOpen) {
achievementPanelOpen = false;
event.preventDefault();
return;
}
// ConfigSidebar handles its own escape via store
if (get(configStore.isSidebarOpen)) {
configStore.closeSidebar();
event.preventDefault();
return;
}
}
// Skip other shortcuts if user is typing in an input
if (isInputFocused) return;
// Ctrl+L - Clear terminal
if (event.ctrlKey && event.key === "l") {
event.preventDefault();
claudeStore.clearTerminal();
return;
}
// Ctrl+, - Open settings
if (event.ctrlKey && event.key === ",") {
event.preventDefault();
configStore.openSidebar();
return;
}
// Ctrl+C - Interrupt (only when processing)
if (event.ctrlKey && event.key === "c") {
if (get(isClaudeProcessing)) {
event.preventDefault();
handleInterrupt();
return;
}
}
// Ctrl++ or Ctrl+= - Increase font size
if (event.ctrlKey && (event.key === "+" || event.key === "=")) {
event.preventDefault();
configStore.increaseFontSize();
return;
}
// Ctrl+- - Decrease font size
if (event.ctrlKey && event.key === "-") {
event.preventDefault();
configStore.decreaseFontSize();
return;
}
// Ctrl+0 - Reset font size
if (event.ctrlKey && event.key === "0") {
event.preventDefault();
configStore.resetFontSize();
return;
}
// Ctrl+Shift+S - Toggle streamer mode
if (event.ctrlKey && event.shiftKey && event.key === "S") {
event.preventDefault();
configStore.toggleStreamerMode();
return;
}
// Ctrl+Shift+M - Toggle compact mode
if (event.ctrlKey && event.shiftKey && event.key === "M") {
event.preventDefault();
toggleCompactMode();
return;
}
// Ctrl+` - Toggle debug console
if (event.ctrlKey && event.key === "`") {
event.preventDefault();
debugConsoleStore.toggle();
return;
}
// Ctrl+E - Toggle editor panel (only when connected)
if (event.ctrlKey && event.key === "e") {
event.preventDefault();
// Only allow opening the editor when connected
if (get(claudeStore.connectionStatus) === "connected") {
editorStore.toggleEditor();
}
return;
}
// Ctrl+B - Toggle file browser (when editor is visible)
if (event.ctrlKey && event.key === "b" && get(editorStore.isEditorVisible)) {
event.preventDefault();
editorStore.toggleFileBrowser();
return;
}
// Ctrl+S - Save current file (when editor is visible)
if (event.ctrlKey && event.key === "s" && get(editorStore.isEditorVisible)) {
event.preventDefault();
editorStore.saveFile();
return;
}
// Ctrl+W - Close current tab (when editor is visible)
if (event.ctrlKey && event.key === "w" && get(editorStore.isEditorVisible)) {
event.preventDefault();
const activeTabId = get(editorStore.activeTabId);
if (activeTabId) {
editorStore.closeTab(activeTabId);
}
return;
}
// Ctrl+N - New file (when editor is visible)
// Note: This just emits an event that FileBrowser listens to
if (event.ctrlKey && event.key === "n" && get(editorStore.isEditorVisible)) {
event.preventDefault();
// Dispatch a custom event that FileBrowser will listen to
window.dispatchEvent(new CustomEvent("editor-new-file"));
return;
}
}
async function handleInterrupt() {
try {
const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) return;
// Set flag to preserve stats/permissions (don't treat next connect as new session)
setSkipNextGreeting(true);
await invoke("interrupt_claude", { conversationId });
claudeStore.addLine("system", "Process interrupted");
} catch (error) {
console.error("Failed to interrupt:", error);
}
}
async function enterCompactMode() {
try {
const window = getCurrentWindow();
const currentSize = await window.innerSize();
const scaleFactor = await window.scaleFactor();
// Save current window size (convert from physical to logical pixels)
// innerSize() returns physical pixels, but setSize() with LogicalSize expects logical
previousWindowSize = {
width: Math.round(currentSize.width / scaleFactor),
height: Math.round(currentSize.height / scaleFactor),
};
// Resize to compact dimensions
await window.setSize(new LogicalSize(COMPACT_WIDTH, COMPACT_HEIGHT));
// Enable compact mode in config
await configStore.setCompactMode(true);
} catch (error) {
console.error("Failed to enter compact mode:", error);
}
}
async function exitCompactMode() {
try {
const window = getCurrentWindow();
// Only resize if we have a saved previous size
// (i.e., user entered compact mode during this session)
// Otherwise, just expand to a reasonable default
if (previousWindowSize) {
await window.setSize(new LogicalSize(previousWindowSize.width, previousWindowSize.height));
previousWindowSize = null;
} else {
// No saved size (e.g., app started in compact mode) - use modest default
await window.setSize(new LogicalSize(900, 650));
}
// Disable compact mode in config
await configStore.setCompactMode(false);
} catch (error) {
console.error("Failed to exit compact mode:", error);
}
}
async function toggleCompactMode() {
if (compactModeActive) {
await exitCompactMode();
} else {
await enterCompactMode();
}
}
async function handleCloseRequest() {
// Check if there's an active conversation with Claude running
const activeId = get(claudeStore.activeConversationId);
if (activeId) {
try {
const isRunning = await invoke<boolean>("is_claude_running", {
conversationId: activeId,
});
hasActiveConversation = isRunning;
} catch (error) {
console.error("Failed to check Claude status:", error);
hasActiveConversation = false;
}
} else {
hasActiveConversation = false;
}
// Always show confirmation modal
closeConfirmModalOpen = true;
}
async function handleConfirmClose() {
closeConfirmModalOpen = false;
try {
await invoke("close_application");
} catch (error) {
console.error("Failed to close application:", error);
}
}
async function handleMinimizeToTray() {
closeConfirmModalOpen = false;
try {
const window = getCurrentWindow();
await window.hide();
} catch (error) {
console.error("Failed to minimize to tray:", error);
}
}
function handleCancelClose() {
closeConfirmModalOpen = false;
}
onMount(async () => {
if (!initialized) {
initialized = true;
// Initialize conversations store first to ensure activeConversationId is set
conversationsStore.initialize();
await initializeTauriListeners();
await configStore.loadConfig();
// Apply saved settings on startup
const config = configStore.getConfig();
applyTheme(config.theme, config.custom_theme_colors);
applyFontSize(config.font_size);
// Apply always-on-top setting
if (config.always_on_top) {
const window = getCurrentWindow();
await window.setAlwaysOnTop(true);
}
// Load saved panel width
if (config.character_panel_width) {
panelWidth = config.character_panel_width;
}
// Initialize notification settings sync
initNotificationSync();
// Add global keyboard shortcut listener
window.addEventListener("keydown", handleGlobalKeydown);
// Check for updates on startup
if (config.update_checks_enabled) {
updateNotification?.checkForUpdates();
}
// Apply compact mode if saved (resize window)
if (config.compact_mode) {
const window = getCurrentWindow();
await window.setSize(new LogicalSize(COMPACT_WIDTH, COMPACT_HEIGHT));
}
// Initialize Discord RPC
await initializeDiscordRpc();
// Initialize todo listener
await initializeTodoListener();
// Listen for window close requests
const unlisten = await listen("window-close-requested", () => {
handleCloseRequest();
});
// Store the unlisten function for cleanup
window.addEventListener("beforeunload", () => {
unlisten();
});
}
});
onDestroy(() => {
if (initialized) {
cleanupTauriListeners();
cleanupNotificationSync();
cleanupTodoListener();
stopDiscordRpc();
window.removeEventListener("keydown", handleGlobalKeydown);
initialized = false;
}
});
</script>
{#if compactModeActive}
<!-- Compact mode: minimal widget interface -->
<div
class="app-container compact-app h-screen w-screen flex flex-col bg-[var(--bg-primary)] overflow-hidden"
>
<CompactMode onExpand={exitCompactMode} />
</div>
{:else}
<!-- Full mode: standard interface -->
<div class="app-container h-screen w-screen flex flex-col bg-[var(--bg-primary)] overflow-hidden">
<StatusBar
onToggleAchievements={() => (achievementPanelOpen = !achievementPanelOpen)}
onToggleCompact={enterCompactMode}
/>
<main class="flex-1 flex overflow-hidden">
<!-- Left panel: Character display -->
<div
class="character-panel {getPanelGlowClass()} flex flex-col items-center justify-center bg-[var(--bg-secondary)]/50"
style="width: {panelWidth}px; min-width: {MIN_PANEL_WIDTH}px; max-width: {MAX_PANEL_WIDTH}px;"
>
<AnimeGirl />
</div>
<!-- Resize handle -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="resize-handle w-1 cursor-col-resize bg-[var(--border-color)] hover:bg-[var(--accent-primary)] transition-colors flex-shrink-0"
class:bg-[var(--accent-primary)]={isResizing}
onmousedown={startResize}
></div>
<!-- Right panel: Terminal/Editor and input -->
<div class="terminal-panel flex-1 flex flex-col min-w-0">
{#if $isEditorVisible}
<EditorPanel />
{:else}
<Terminal />
<InputBar />
{/if}
</div>
</main>
<PermissionModal />
<UserQuestionModal />
<ConfigSidebar />
<MemoryBrowserPanel />
<AchievementNotification />
<AchievementsPanel
bind:isOpen={achievementPanelOpen}
onClose={() => (achievementPanelOpen = false)}
/>
<UpdateNotification bind:this={updateNotification} />
<CloseAppConfirmModal
isOpen={closeConfirmModalOpen}
{hasActiveConversation}
onClose={handleConfirmClose}
onMinimize={handleMinimizeToTray}
onCancel={handleCancelClose}
/>
</div>
{/if}
<style>
.app-container {
font-family:
"Inter",
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
sans-serif;
}
.character-panel {
background: linear-gradient(180deg, var(--bg-secondary) 0%, var(--bg-primary) 100%);
transition: all 0.5s ease;
position: relative;
}
.character-panel::before {
content: "";
position: absolute;
inset: 0;
padding: 3px;
background: transparent;
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
opacity: 0;
transition: opacity 0.5s ease;
pointer-events: none;
}
/* Trans pride gradient glow effects for the character panel */
.panel-glow-thinking {
background: linear-gradient(
180deg,
color-mix(in srgb, var(--bg-secondary) 85%, #9333ea) 0%,
color-mix(in srgb, var(--bg-primary) 90%, var(--trans-blue)) 50%,
color-mix(in srgb, var(--bg-primary) 90%, var(--trans-pink)) 100%
);
box-shadow:
inset 0 0 60px rgba(147, 51, 234, 0.15),
inset 0 0 100px rgba(91, 206, 250, 0.1),
0 0 40px rgba(91, 206, 250, 0.2),
0 0 80px rgba(245, 169, 184, 0.15);
}
.panel-glow-thinking::before {
background: linear-gradient(180deg, #9333ea, var(--trans-blue), var(--trans-pink));
opacity: 1;
}
.panel-glow-typing {
background: linear-gradient(
180deg,
color-mix(in srgb, var(--bg-secondary) 85%, #3b82f6) 0%,
color-mix(in srgb, var(--bg-primary) 90%, var(--trans-blue)) 50%,
color-mix(in srgb, var(--bg-primary) 90%, var(--trans-pink)) 100%
);
box-shadow:
inset 0 0 60px rgba(59, 130, 246, 0.15),
inset 0 0 100px rgba(91, 206, 250, 0.15),
0 0 40px rgba(91, 206, 250, 0.25),
0 0 80px rgba(245, 169, 184, 0.15);
}
.panel-glow-typing::before {
background: linear-gradient(180deg, #3b82f6, var(--trans-blue), var(--trans-pink));
opacity: 1;
}
.panel-glow-searching {
background: linear-gradient(
180deg,
color-mix(in srgb, var(--bg-secondary) 85%, #eab308) 0%,
color-mix(in srgb, var(--bg-primary) 90%, var(--trans-blue)) 50%,
color-mix(in srgb, var(--bg-primary) 90%, var(--trans-pink)) 100%
);
box-shadow:
inset 0 0 60px rgba(234, 179, 8, 0.15),
inset 0 0 100px rgba(91, 206, 250, 0.1),
0 0 40px rgba(91, 206, 250, 0.2),
0 0 80px rgba(245, 169, 184, 0.15);
}
.panel-glow-searching::before {
background: linear-gradient(180deg, #eab308, var(--trans-blue), var(--trans-pink));
opacity: 1;
}
.panel-glow-coding {
background: linear-gradient(
180deg,
color-mix(in srgb, var(--bg-secondary) 85%, #22c55e) 0%,
color-mix(in srgb, var(--bg-primary) 90%, var(--trans-blue)) 50%,
color-mix(in srgb, var(--bg-primary) 90%, var(--trans-pink)) 100%
);
box-shadow:
inset 0 0 60px rgba(34, 197, 94, 0.15),
inset 0 0 100px rgba(91, 206, 250, 0.1),
0 0 40px rgba(91, 206, 250, 0.2),
0 0 80px rgba(245, 169, 184, 0.15);
}
.panel-glow-coding::before {
background: linear-gradient(180deg, #22c55e, var(--trans-blue), var(--trans-pink));
opacity: 1;
}
.panel-glow-mcp {
background: linear-gradient(
180deg,
color-mix(in srgb, var(--bg-secondary) 80%, var(--trans-blue)) 0%,
color-mix(in srgb, var(--bg-primary) 85%, var(--trans-pink)) 50%,
color-mix(in srgb, var(--bg-primary) 90%, var(--trans-blue)) 100%
);
box-shadow:
inset 0 0 80px rgba(91, 206, 250, 0.2),
inset 0 0 120px rgba(245, 169, 184, 0.15),
0 0 60px rgba(91, 206, 250, 0.3),
0 0 100px rgba(245, 169, 184, 0.2);
}
.panel-glow-mcp::before {
background: var(--trans-gradient-vibrant);
opacity: 1;
}
.panel-glow-success {
background: linear-gradient(
180deg,
color-mix(in srgb, var(--bg-secondary) 85%, #10b981) 0%,
color-mix(in srgb, var(--bg-primary) 90%, var(--trans-blue)) 50%,
color-mix(in srgb, var(--bg-primary) 90%, var(--trans-pink)) 100%
);
box-shadow:
inset 0 0 60px rgba(16, 185, 129, 0.15),
inset 0 0 100px rgba(91, 206, 250, 0.1),
0 0 40px rgba(91, 206, 250, 0.2),
0 0 80px rgba(245, 169, 184, 0.15);
}
.panel-glow-success::before {
background: linear-gradient(180deg, #10b981, var(--trans-blue), var(--trans-pink));
opacity: 1;
}
.panel-glow-error {
background: linear-gradient(
180deg,
color-mix(in srgb, var(--bg-secondary) 80%, #ef4444) 0%,
color-mix(in srgb, var(--bg-primary) 90%, var(--trans-pink)) 50%,
color-mix(in srgb, var(--bg-primary) 95%, var(--trans-blue)) 100%
);
box-shadow:
inset 0 0 60px rgba(239, 68, 68, 0.2),
inset 0 0 100px rgba(245, 169, 184, 0.1),
0 0 40px rgba(245, 169, 184, 0.2),
0 0 80px rgba(239, 68, 68, 0.15);
}
.panel-glow-error::before {
background: linear-gradient(180deg, #ef4444, var(--trans-pink), var(--trans-blue));
opacity: 1;
}
.resize-handle:hover,
.resize-handle:active {
width: 4px;
}
</style>