diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 2965410..29d3477 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -1,22 +1,22 @@ import { Component, Match, Switch, createSignal, onMount } from "solid-js"; import ChatFeed from "./components/ChatFeed"; +import Header from "./components/Header"; import SignIn from "./components/SignIn"; -import { getAuthStatus, type AuthStatusState } from "./lib/twitchAuth"; +import { getAuthStatus, type AuthStatus } from "./lib/twitchAuth"; const App: Component = () => { // `null` = still loading initial status; the splash avoids a flash of // the SignIn overlay before the keychain check returns. - const [authState, setAuthState] = createSignal(null); + const [auth, setAuth] = createSignal(null); onMount(async () => { try { - const status = await getAuthStatus(); - setAuthState(status.state); + setAuth(await getAuthStatus()); } catch { // Treat any error from the status command as logged-out — the // SignIn flow surfaces a real error message if the underlying // keychain is broken. - setAuthState("logged_out"); + setAuth({ state: "logged_out" }); } }); @@ -25,7 +25,7 @@ const App: Component = () => { style={{ display: "flex", "flex-direction": "column", height: "100%" }} > - +
{ Loading...
- - setAuthState("logged_in")} /> + + setAuth({ state: "logged_in", login })} + /> - - + { + const a = auth(); + return a?.state === "logged_in" ? a : null; + })()} + > + {(loggedIn) => ( + <> +
+ + + )} diff --git a/apps/desktop/src/components/Header.tsx b/apps/desktop/src/components/Header.tsx new file mode 100644 index 0000000..bf8784e --- /dev/null +++ b/apps/desktop/src/components/Header.tsx @@ -0,0 +1,128 @@ +// Top chrome for the chat window. Shows the active channel, the platform +// it belongs to, and a live connection indicator fed by the supervisor's +// `sidecar_status` event. Purely presentational: no commands invoked, so +// a broken supervisor can't brick the UI. + +import { Component, Show, createSignal, onCleanup, onMount } from "solid-js"; +import { listen, type UnlistenFn } from "@tauri-apps/api/event"; +import { + indicatorFor, + type SidecarState, + type SidecarStatus, +} from "../lib/sidecarStatus"; + +export interface HeaderProps { + login: string; +} + +const Header: Component = (props) => { + const [state, setState] = createSignal(null); + let unlisten: UnlistenFn | undefined; + // listen() is async, so a fast unmount can race the registration: if + // onCleanup runs first, `unlisten` is still undefined and the listener + // would leak when the promise resolves later. Track disposal explicitly + // so the late-arriving handle can be torn down immediately. + let disposed = false; + + onMount(() => { + listen("sidecar_status", (evt) => { + setState(evt.payload.state); + }) + .then((next) => { + if (disposed) { + next(); + return; + } + unlisten = next; + }) + .catch((err: unknown) => { + console.error("failed to subscribe to sidecar_status", err); + }); + }); + + onCleanup(() => { + disposed = true; + unlisten?.(); + unlisten = undefined; + }); + + return ( +
+ + T + + {props.login} + + +
+ ); +}; + +const ConnectionChip: Component<{ state: SidecarState | null }> = (props) => { + const info = () => indicatorFor(props.state); + return ( + + + {info().label} + + ); +}; + +const Dot: Component<{ color: string }> = (props) => { + return ( + + ); +}; + +export default Header; diff --git a/apps/desktop/src/lib/sidecarStatus.test.ts b/apps/desktop/src/lib/sidecarStatus.test.ts new file mode 100644 index 0000000..ece0381 --- /dev/null +++ b/apps/desktop/src/lib/sidecarStatus.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { indicatorFor } from "./sidecarStatus"; + +describe("indicatorFor", () => { + it("running → green connected", () => { + expect(indicatorFor("running")).toEqual({ + label: "Connected", + color: "#3fb950", + }); + }); + + it("spawning and backoff share the amber connecting indicator", () => { + expect(indicatorFor("spawning")).toEqual({ + label: "Connecting", + color: "#d29922", + }); + expect(indicatorFor("backoff")).toEqual({ + label: "Connecting", + color: "#d29922", + }); + }); + + it("waiting_for_auth surfaces a sign-in hint", () => { + expect(indicatorFor("waiting_for_auth")).toEqual({ + label: "Waiting for sign-in", + color: "#d29922", + }); + }); + + it("unhealthy and terminated are distinct", () => { + expect(indicatorFor("unhealthy")).toEqual({ + label: "Unhealthy", + color: "#db6d28", + }); + expect(indicatorFor("terminated")).toEqual({ + label: "Disconnected", + color: "#f85149", + }); + }); + + it("null before any event reports a starting state", () => { + expect(indicatorFor(null)).toEqual({ + label: "Starting", + color: "#6e7681", + }); + }); +}); diff --git a/apps/desktop/src/lib/sidecarStatus.ts b/apps/desktop/src/lib/sidecarStatus.ts new file mode 100644 index 0000000..8b0263b --- /dev/null +++ b/apps/desktop/src/lib/sidecarStatus.ts @@ -0,0 +1,40 @@ +// Pure state→indicator mapping shared with the header component. +// Split out so unit tests don't need to resolve the Solid/Tauri +// component module graph. + +export type SidecarState = + | "spawning" + | "waiting_for_auth" + | "backoff" + | "running" + | "unhealthy" + | "terminated"; + +export interface SidecarStatus { + state: SidecarState; + attempt: number; + backoff_ms?: number; +} + +export interface Indicator { + label: string; + color: string; +} + +export function indicatorFor(state: SidecarState | null): Indicator { + switch (state) { + case "running": + return { label: "Connected", color: "#3fb950" }; + case "spawning": + case "backoff": + return { label: "Connecting", color: "#d29922" }; + case "waiting_for_auth": + return { label: "Waiting for sign-in", color: "#d29922" }; + case "unhealthy": + return { label: "Unhealthy", color: "#db6d28" }; + case "terminated": + return { label: "Disconnected", color: "#f85149" }; + default: + return { label: "Starting", color: "#6e7681" }; + } +} diff --git a/apps/desktop/src/lib/twitchAuth.ts b/apps/desktop/src/lib/twitchAuth.ts index 356c24b..673c034 100644 --- a/apps/desktop/src/lib/twitchAuth.ts +++ b/apps/desktop/src/lib/twitchAuth.ts @@ -6,10 +6,13 @@ import { open } from "@tauri-apps/plugin-shell"; export type AuthStatusState = "logged_out" | "logged_in"; -export interface AuthStatus { - state: AuthStatusState; - login?: string; -} +// Discriminated union: when state is logged_in, login is guaranteed by +// the backend (twitch_auth::commands::twitch_auth_status). Modeling it +// this way prevents the UI from silently rendering an empty username if +// a malformed payload ever slips through. +export type AuthStatus = + | { state: "logged_out" } + | { state: "logged_in"; login: string }; export interface DeviceCodeView { verification_uri: string;