Files
openchamber/packages/ui/src/components/chat/StatusRow.tsx
T
Bohdan Triapitsyn 83ffb1af34 refactor(desktop): make Tauri thin shell running web sidecar (#273)
## What / Why
This PR finishes the desktop refactor: the Tauri app is now a thin shell that launches the web server as a sidecar and loads the UI from `http://127.0.0.1:<port>`. All real backend logic lives in `packages/web/server/index.js`; desktop Rust keeps only native integrations (menu/dialog/notifications/updater/deep-link + window chrome).
This unblocks:
- consistent behavior across web/desktop/vscode (single backend)
- simpler desktop maintenance (no duplicated Rust backend)
- host switching between Local + remote instances in desktop
- reliable cold-start behavior on slow machines (VSCode + desktop)
## Key changes
- Desktop sidecar runtime
  - build pipeline to bundle web dist + `openchamber-server` sidecar (`packages/desktop/scripts/build-sidecar.mjs`)
  - robust local port selection (prefer saved/default, fallback to random; persisted in `~/.config/openchamber/settings.json`)
  - improved PATH handling so the sidecar can locate `opencode` CLI (incl `~/.opencode/bin`, overrides, common bins)
  - disable native right-click context menu in production builds (dev keeps it)
- Desktop instance switcher (Tauri-only)
  - header button + modal to add/edit/delete remote hosts, set default, probe status/ping, switch back to Local escape hatch
  - auth gate includes host switcher so you can recover when a remote host is broken/auth-required
  - host list stored desktop-locally (not tied to the currently selected remote server)
- Notifications
  - decision logic moved server-side; desktop notifications emitted via sidecar stdout and shown natively by Tauri
  - prevent double-notifications on desktop Local origin (UI ignores SSE notification when native path is active)
  - restore macOS notification sound
- Updates
  - Tauri updater used only when viewing Local instance in desktop shell (avoid “remote web update” triggering desktop restart)
- Settings persistence & UX polish
  - persist model favorites/recents via `/api/config/settings` (works for web + desktop; not origin-dependent)
  - persist per-project sidebar collapse state in `projects[].sidebarCollapsed` via `/api/config/settings` (with debounce on toggles)
  - macOS header sizing/traffic-lights offsets fixed (marketing macOS major injected from desktop; MultiRun header aligned)
  - VSCode cold-start: keep retrying provider/agent loads after connection to avoid empty UI on slow machines
  - misc lint/type fixes + bun.lock sync
- Desktop bootstrap / resiliency
  - show onboarding screen when OpenCode CLI is missing (desktop Local origin), with retry hook to restart OpenCode after install
## Testing notes
- Desktop (macOS): switch Local <-> remote, set default host, verify auth gate recovery, native notifications (with sound), updater gated to Local
- Web: favorites/recents + per-project collapsed state persist across reload/restart
- VSCode: slow startup no longer results in missing providers/agents/models
2026-02-05 01:59:49 +02:00

256 lines
8.0 KiB
TypeScript

import React from "react";
import { RiArrowUpSLine, RiArrowDownSLine, RiCloseCircleLine } from "@remixicon/react";
import { cn } from "@/lib/utils";
import { useTodoStore, type TodoItem, type TodoStatus } from "@/stores/useTodoStore";
import { useSessionStore } from "@/stores/useSessionStore";
import { useUIStore } from "@/stores/useUIStore";
import { WorkingPlaceholder } from "./message/parts/WorkingPlaceholder";
import { isVSCodeRuntime } from "@/lib/desktop";
const statusConfig: Record<TodoStatus, { textClassName: string }> = {
in_progress: {
textClassName: "text-foreground",
},
pending: {
textClassName: "text-foreground",
},
completed: {
textClassName: "text-muted-foreground line-through",
},
cancelled: {
textClassName: "text-muted-foreground line-through",
},
};
interface TodoItemRowProps {
todo: TodoItem;
}
const TodoItemRow: React.FC<TodoItemRowProps> = ({ todo }) => {
const config = statusConfig[todo.status] || statusConfig.pending;
return (
<div className="flex items-start min-w-0 py-0.5">
<span
className={cn(
"flex-1 typography-ui-label",
config.textClassName
)}
>
{todo.content}
</span>
</div>
);
};
const EMPTY_TODOS: TodoItem[] = [];
interface StatusRowProps {
// Working state
isWorking: boolean;
statusText: string | null;
isGenericStatus?: boolean;
isWaitingForPermission?: boolean;
wasAborted?: boolean;
abortActive?: boolean;
// Abort state (for mobile/vscode)
showAbort?: boolean;
onAbort?: () => void;
// Abort status display
showAbortStatus?: boolean;
}
export const StatusRow: React.FC<StatusRowProps> = ({
isWorking,
statusText,
isGenericStatus,
isWaitingForPermission,
wasAborted,
abortActive,
showAbort,
onAbort,
showAbortStatus,
}) => {
const [isExpanded, setIsExpanded] = React.useState(false);
const currentSessionId = useSessionStore((state) => state.currentSessionId);
const todos = useTodoStore((state) =>
currentSessionId ? state.sessionTodos.get(currentSessionId) ?? EMPTY_TODOS : EMPTY_TODOS
);
const loadTodos = useTodoStore((state) => state.loadTodos);
const { isMobile } = useUIStore();
const isCompact = isMobile || isVSCodeRuntime();
// Load todos when session changes
React.useEffect(() => {
if (currentSessionId) {
void loadTodos(currentSessionId);
}
}, [currentSessionId, loadTodos]);
// Filter out cancelled todos for display, sort by status priority
const visibleTodos = React.useMemo(() => {
const statusOrder: Record<TodoStatus, number> = {
in_progress: 0,
pending: 1,
completed: 2,
cancelled: 3,
};
return [...todos]
.filter((todo) => todo.status !== "cancelled")
.sort((a, b) => statusOrder[a.status] - statusOrder[b.status]);
}, [todos]);
// Find the current active todo (first in_progress, or first pending)
const activeTodo = React.useMemo(() => {
return (
visibleTodos.find((t) => t.status === "in_progress") ||
visibleTodos.find((t) => t.status === "pending") ||
null
);
}, [visibleTodos]);
// Calculate progress
const progress = React.useMemo(() => {
const total = todos.filter((t) => t.status !== "cancelled").length;
const completed = todos.filter((t) => t.status === "completed").length;
return { completed, total };
}, [todos]);
const hasActiveTodos = visibleTodos.some((t) => t.status === "in_progress" || t.status === "pending");
// Original logic from ChatInput
const shouldRenderPlaceholder = !showAbortStatus && (wasAborted || !abortActive);
// Keep StatusRow rendered while:
// - isWorking (active session)
// - wasAborted / showAbortStatus
// - hasActiveTodos
const hasContent =
isWorking ||
Boolean(wasAborted) ||
Boolean(showAbortStatus) ||
hasActiveTodos;
// Close popover when clicking outside
const popoverRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (!isExpanded) return;
const handleClickOutside = (event: MouseEvent) => {
if (popoverRef.current && !popoverRef.current.contains(event.target as Node)) {
setIsExpanded(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isExpanded]);
// Don't render if nothing to show
if (!hasContent) {
return null;
}
const toggleExpanded = () => setIsExpanded((prev) => !prev);
// Abort button for mobile/vscode
const abortButton = showAbort && onAbort ? (
<button
type="button"
onClick={onAbort}
className="flex items-center justify-center h-[1.2rem] w-[1.2rem] text-[var(--status-error)] transition-opacity hover:opacity-80 focus-visible:outline-none flex-shrink-0"
aria-label="Stop generating"
>
<RiCloseCircleLine size={18} aria-hidden="true" />
</button>
) : null;
// Todo trigger button
const todoTrigger = hasActiveTodos ? (
<button
type="button"
onClick={toggleExpanded}
className="flex items-center gap-1 flex-shrink-0 text-muted-foreground"
>
{/* Desktop: show task text; Mobile/VSCode: just "Tasks" */}
{!isCompact && activeTodo ? (
<span className="typography-ui-label text-foreground truncate max-w-[200px]">
{activeTodo.content}
</span>
) : (
<span className="typography-ui-label">Tasks</span>
)}
<span className="typography-meta">
{progress.completed}/{progress.total}
</span>
{isExpanded ? (
<RiArrowUpSLine className="h-3.5 w-3.5" />
) : (
<RiArrowDownSLine className="h-3.5 w-3.5" />
)}
</button>
) : null;
return (
<div className="chat-column mb-1" style={{ containerType: "inline-size" }}>
{/* Main status row */}
<div className="flex items-center justify-between pr-[2ch] py-0.5 gap-2 h-[1.2rem]">
{/* Left: Abort status or Working placeholder */}
<div className="flex-1 flex items-center overflow-hidden min-w-0">
{showAbortStatus ? (
<div className="flex h-full items-center text-[var(--status-error)] pl-[2ch]">
<span className="flex items-center gap-1.5 typography-ui-label">
<RiCloseCircleLine size={16} aria-hidden="true" />
Aborted
</span>
</div>
) : shouldRenderPlaceholder ? (
<WorkingPlaceholder
key={currentSessionId ?? "no-session"}
isWorking={isWorking}
statusText={statusText}
isGenericStatus={isGenericStatus}
isWaitingForPermission={isWaitingForPermission}
/>
) : null}
</div>
{/* Right: Abort (mobile only) + Todo */}
<div className="relative flex items-center gap-2 flex-shrink-0" ref={popoverRef}>
{abortButton}
{todoTrigger}
{/* Popover dropdown */}
{isExpanded && hasActiveTodos && (
<div
style={{ maxWidth: "calc(100cqw - 4ch)" }}
className={cn(
"absolute right-0 bottom-full mb-1 z-50",
"w-max min-w-[200px]",
"rounded-xl border border-border bg-background shadow-md",
"animate-in fade-in-0 zoom-in-95 slide-in-from-bottom-2",
"duration-150"
)}
>
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-border">
<span className="typography-ui-label text-muted-foreground">Tasks</span>
<span className="typography-meta text-muted-foreground">
{progress.completed}/{progress.total}
</span>
</div>
{/* Todo list */}
<div className="px-3 py-2 max-h-[200px] overflow-y-auto divide-y divide-border">
{visibleTodos.map((todo) => (
<TodoItemRow key={todo.id} todo={todo} />
))}
</div>
</div>
)}
</div>
</div>
</div>
);
};