fix(web): restore chat composer disabled-state messaging and clean up code (#2526)

This commit is contained in:
wenjie 2026-04-15 11:24:27 +08:00 committed by GitHub
parent 773a94c414
commit 51ab3b1385
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 184 additions and 161 deletions

View file

@ -41,9 +41,7 @@ export async function postLauncherDashboardLogout(): Promise<boolean> {
return res.ok return res.ok
} }
export type SetupResult = export type SetupResult = { ok: true } | { ok: false; error: string }
| { ok: true }
| { ok: false; error: string }
export async function postLauncherDashboardSetup( export async function postLauncherDashboardSetup(
password: string, password: string,
@ -53,7 +51,10 @@ export async function postLauncherDashboardSetup(
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
credentials: "same-origin", credentials: "same-origin",
body: JSON.stringify({ password: password.trim(), confirm: confirm.trim() }), body: JSON.stringify({
password: password.trim(),
confirm: confirm.trim(),
}),
}) })
if (res.ok) return { ok: true } if (res.ok) return { ok: true }
let msg = "Unknown error" let msg = "Unknown error"

View file

@ -14,6 +14,7 @@ import { Link } from "@tanstack/react-router"
import * as React from "react" import * as React from "react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { postLauncherDashboardLogout } from "@/api/launcher-auth"
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@ -40,7 +41,6 @@ import {
} from "@/components/ui/tooltip" } from "@/components/ui/tooltip"
import { useGateway } from "@/hooks/use-gateway.ts" import { useGateway } from "@/hooks/use-gateway.ts"
import { useTheme } from "@/hooks/use-theme.ts" import { useTheme } from "@/hooks/use-theme.ts"
import { postLauncherDashboardLogout } from "@/api/launcher-auth"
export function AppHeader() { export function AppHeader() {
const { i18n, t } = useTranslation() const { i18n, t } = useTranslation()
@ -198,27 +198,42 @@ export function AppHeader() {
<IconPower className="h-4 w-4 opacity-80" /> <IconPower className="h-4 w-4 opacity-80" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>{gwError ?? t("header.gateway.action.stop")}</TooltipContent> <TooltipContent>
{gwError ?? t("header.gateway.action.stop")}
</TooltipContent>
</Tooltip> </Tooltip>
) : ( ) : (
<Tooltip delayDuration={(gwError || (!canStart && startReason)) ? 0 : 700}> <Tooltip
delayDuration={gwError || (!canStart && startReason) ? 0 : 700}
>
<TooltipTrigger asChild> <TooltipTrigger asChild>
{/* Wrap in span so the tooltip still fires when the button is disabled */} {/* Wrap in span so the tooltip still fires when the button is disabled */}
<span <span
className={!canStart && startReason ? "cursor-not-allowed" : undefined} className={
!canStart && startReason ? "cursor-not-allowed" : undefined
}
tabIndex={!canStart && startReason ? 0 : undefined} tabIndex={!canStart && startReason ? 0 : undefined}
> >
<Button <Button
variant={ variant={
isStarting || isRestarting || isStopping ? "secondary" : "default" isStarting || isRestarting || isStopping
? "secondary"
: "default"
} }
size="sm" size="sm"
data-tour="gateway-button" data-tour="gateway-button"
className={`h-8 gap-2 px-3 ${isStopped ? "bg-green-500 text-white hover:bg-green-600" : "" className={`h-8 gap-2 px-3 ${
} ${!canStart ? "pointer-events-none" : ""}`} isStopped
? "bg-green-500 text-white hover:bg-green-600"
: ""
} ${!canStart ? "pointer-events-none" : ""}`}
onClick={handleGatewayToggle} onClick={handleGatewayToggle}
disabled={ disabled={
gwLoading || isStarting || isRestarting || isStopping || !canStart gwLoading ||
isStarting ||
isRestarting ||
isStopping ||
!canStart
} }
> >
{gwLoading || isStarting || isRestarting || isStopping ? ( {gwLoading || isStarting || isRestarting || isStopping ? (
@ -238,7 +253,7 @@ export function AppHeader() {
</Button> </Button>
</span> </span>
</TooltipTrigger> </TooltipTrigger>
{(gwError || (!canStart && startReason)) ? ( {gwError || (!canStart && startReason) ? (
<TooltipContent>{gwError ?? startReason}</TooltipContent> <TooltipContent>{gwError ?? startReason}</TooltipContent>
) : null} ) : null}
</Tooltip> </Tooltip>

View file

@ -42,15 +42,11 @@ export function ChatComposer({
}: ChatComposerProps) { }: ChatComposerProps) {
const { t } = useTranslation() const { t } = useTranslation()
const canInput = inputDisabledReason === null const canInput = inputDisabledReason === null
const placeholder = canInput const disabledMessage =
? t("chat.placeholder") inputDisabledReason === null
: t(`chat.disabledPlaceholder.${inputDisabledReason}`) ? null
: t(`chat.disabledPlaceholder.${inputDisabledReason}`)
const inputDisabledReason = (() => { const placeholder = disabledMessage ?? t("chat.placeholder")
if (!isConnected) return t("chat.inputDisabled.notConnected")
if (!hasDefaultModel) return t("chat.inputDisabled.noModel")
return null
})()
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => { const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.nativeEvent.isComposing) return if (e.nativeEvent.isComposing) return
@ -95,7 +91,7 @@ export function ChatComposer({
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder={placeholder} placeholder={placeholder}
disabled={!canInput} disabled={!canInput}
title={inputDisabledReason || undefined} title={disabledMessage || undefined}
className={cn( className={cn(
"placeholder:text-muted-foreground/50 max-h-[200px] min-h-[60px] resize-none border-0 bg-transparent px-2 py-1 text-[15px] shadow-none transition-colors focus-visible:ring-0 focus-visible:outline-none dark:bg-transparent", "placeholder:text-muted-foreground/50 max-h-[200px] min-h-[60px] resize-none border-0 bg-transparent px-2 py-1 text-[15px] shadow-none transition-colors focus-visible:ring-0 focus-visible:outline-none dark:bg-transparent",
!canInput && "cursor-not-allowed", !canInput && "cursor-not-allowed",
@ -103,9 +99,9 @@ export function ChatComposer({
minRows={1} minRows={1}
maxRows={8} maxRows={8}
/> />
{!canInput && inputDisabledReason && ( {!canInput && disabledMessage && (
<div className="px-3 py-1 text-xs text-muted-foreground"> <div className="text-muted-foreground px-3 py-1 text-xs">
{inputDisabledReason} {disabledMessage}
</div> </div>
)} )}

View file

@ -5,8 +5,8 @@ import { toast } from "sonner"
import { AssistantMessage } from "@/components/chat/assistant-message" import { AssistantMessage } from "@/components/chat/assistant-message"
import { import {
type ChatInputDisabledReason,
ChatComposer, ChatComposer,
type ChatInputDisabledReason,
} from "@/components/chat/chat-composer" } from "@/components/chat/chat-composer"
import { ChatEmptyState } from "@/components/chat/chat-empty-state" import { ChatEmptyState } from "@/components/chat/chat-empty-state"
import { ModelSelector } from "@/components/chat/model-selector" import { ModelSelector } from "@/components/chat/model-selector"

View file

@ -12,10 +12,7 @@ import {
generateSessionId, generateSessionId,
readStoredSessionId, readStoredSessionId,
} from "@/features/chat/state" } from "@/features/chat/state"
import { import { invalidateSocket, isCurrentSocket } from "@/features/chat/websocket"
invalidateSocket,
isCurrentSocket,
} from "@/features/chat/websocket"
import i18n from "@/i18n" import i18n from "@/i18n"
import { import {
type ChatAttachment, type ChatAttachment,

View file

@ -1,10 +1,7 @@
import { toast } from "sonner" import { toast } from "sonner"
import { normalizeUnixTimestamp } from "@/features/chat/state" import { normalizeUnixTimestamp } from "@/features/chat/state"
import { import { type AssistantMessageKind, updateChatStore } from "@/store/chat"
type AssistantMessageKind,
updateChatStore,
} from "@/store/chat"
export interface PicoMessage { export interface PicoMessage {
type: string type: string

View file

@ -77,5 +77,15 @@ export function useGateway() {
} }
}, [state]) }, [state])
return { state, loading, canStart, startReason, restartRequired, start, stop, restart, error } return {
state,
loading,
canStart,
startReason,
restartRequired,
start,
stop,
restart,
error,
}
} }

View file

@ -53,7 +53,9 @@ const RootLayout = () => {
globalThis.location.assign("/launcher-login") globalThis.location.assign("/launcher-login")
} else { } else {
setAuthError( setAuthError(
err instanceof Error ? err.message : "Auth service unavailable, please try to delete the launcher-auth.db at picoclaw home directory and restart the application.", err instanceof Error
? err.message
: "Auth service unavailable, please try to delete the launcher-auth.db at picoclaw home directory and restart the application.",
) )
} }
}) })

View file

@ -3,7 +3,10 @@ import { createFileRoute } from "@tanstack/react-router"
import * as React from "react" import * as React from "react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { postLauncherDashboardLogin, getLauncherAuthStatus } from "@/api/launcher-auth" import {
getLauncherAuthStatus,
postLauncherDashboardLogin,
} from "@/api/launcher-auth"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import {
Card, Card,
@ -37,7 +40,9 @@ function LauncherLoginPage() {
globalThis.location.assign("/launcher-setup") globalThis.location.assign("/launcher-setup")
} }
}) })
.catch(() => { /* network error — stay on login page */ }) .catch(() => {
/* network error — stay on login page */
})
}, []) }, [])
const loginWithToken = React.useCallback( const loginWithToken = React.useCallback(

View file

@ -6,141 +6,141 @@ import { useTranslation } from "react-i18next"
import { postLauncherDashboardSetup } from "@/api/launcher-auth" import { postLauncherDashboardSetup } from "@/api/launcher-auth"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import {
Card, Card,
CardContent, CardContent,
CardDescription, CardDescription,
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card" } from "@/components/ui/card"
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu" } from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { useTheme } from "@/hooks/use-theme" import { useTheme } from "@/hooks/use-theme"
function LauncherSetupPage() { function LauncherSetupPage() {
const { t, i18n } = useTranslation() const { t, i18n } = useTranslation()
const { theme, toggleTheme } = useTheme() const { theme, toggleTheme } = useTheme()
const [password, setPassword] = React.useState("") const [password, setPassword] = React.useState("")
const [confirm, setConfirm] = React.useState("") const [confirm, setConfirm] = React.useState("")
const [submitting, setSubmitting] = React.useState(false) const [submitting, setSubmitting] = React.useState(false)
const [error, setError] = React.useState("") const [error, setError] = React.useState("")
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => { const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault() e.preventDefault()
setError("") setError("")
if (password !== confirm) { if (password !== confirm) {
setError(t("launcherSetup.errorMismatch")) setError(t("launcherSetup.errorMismatch"))
return return
}
setSubmitting(true)
try {
const result = await postLauncherDashboardSetup(password, confirm)
if (result.ok) {
globalThis.location.assign("/launcher-login")
return
}
setError(result.error)
} catch {
setError(t("launcherSetup.errorNetwork"))
} finally {
setSubmitting(false)
}
} }
setSubmitting(true)
try {
const result = await postLauncherDashboardSetup(password, confirm)
if (result.ok) {
globalThis.location.assign("/launcher-login")
return
}
setError(result.error)
} catch {
setError(t("launcherSetup.errorNetwork"))
} finally {
setSubmitting(false)
}
}
return ( return (
<div className="bg-background text-foreground flex min-h-dvh flex-col"> <div className="bg-background text-foreground flex min-h-dvh flex-col">
<header className="border-border/50 flex h-14 shrink-0 items-center justify-end gap-2 border-b px-4"> <header className="border-border/50 flex h-14 shrink-0 items-center justify-end gap-2 border-b px-4">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" aria-label="Language"> <Button variant="outline" size="icon" aria-label="Language">
<IconLanguage className="size-4" /> <IconLanguage className="size-4" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => i18n.changeLanguage("en")}> <DropdownMenuItem onClick={() => i18n.changeLanguage("en")}>
English English
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => i18n.changeLanguage("zh")}> <DropdownMenuItem onClick={() => i18n.changeLanguage("zh")}>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<Button <Button
variant="outline" variant="outline"
size="icon" size="icon"
type="button" type="button"
onClick={() => toggleTheme()} onClick={() => toggleTheme()}
aria-label={theme === "dark" ? "Light mode" : "Dark mode"} aria-label={theme === "dark" ? "Light mode" : "Dark mode"}
> >
{theme === "dark" ? ( {theme === "dark" ? (
<IconSun className="size-4" /> <IconSun className="size-4" />
) : ( ) : (
<IconMoon className="size-4" /> <IconMoon className="size-4" />
)} )}
</Button> </Button>
</header> </header>
<div className="flex flex-1 items-center justify-center p-4"> <div className="flex flex-1 items-center justify-center p-4">
<Card className="w-full max-w-md" size="sm"> <Card className="w-full max-w-md" size="sm">
<CardHeader> <CardHeader>
<CardTitle>{t("launcherSetup.title")}</CardTitle> <CardTitle>{t("launcherSetup.title")}</CardTitle>
<CardDescription>{t("launcherSetup.description")}</CardDescription> <CardDescription>{t("launcherSetup.description")}</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<form className="flex flex-col gap-4" onSubmit={onSubmit}> <form className="flex flex-col gap-4" onSubmit={onSubmit}>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor="setup-password"> <Label htmlFor="setup-password">
{t("launcherSetup.passwordLabel")} {t("launcherSetup.passwordLabel")}
</Label> </Label>
<Input <Input
id="setup-password" id="setup-password"
name="password" name="password"
type="password" type="password"
autoComplete="new-password" autoComplete="new-password"
required required
minLength={8} minLength={8}
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
placeholder={t("launcherSetup.passwordPlaceholder")} placeholder={t("launcherSetup.passwordPlaceholder")}
/> />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor="setup-confirm"> <Label htmlFor="setup-confirm">
{t("launcherSetup.confirmLabel")} {t("launcherSetup.confirmLabel")}
</Label> </Label>
<Input <Input
id="setup-confirm" id="setup-confirm"
name="confirm" name="confirm"
type="password" type="password"
autoComplete="new-password" autoComplete="new-password"
required required
minLength={8} minLength={8}
value={confirm} value={confirm}
onChange={(e) => setConfirm(e.target.value)} onChange={(e) => setConfirm(e.target.value)}
placeholder={t("launcherSetup.confirmPlaceholder")} placeholder={t("launcherSetup.confirmPlaceholder")}
/> />
</div> </div>
<Button type="submit" disabled={submitting}> <Button type="submit" disabled={submitting}>
{submitting ? t("labels.loading") : t("launcherSetup.submit")} {submitting ? t("labels.loading") : t("launcherSetup.submit")}
</Button> </Button>
{error ? ( {error ? (
<p className="text-destructive text-sm" role="alert"> <p className="text-destructive text-sm" role="alert">
{error} {error}
</p> </p>
) : null} ) : null}
</form> </form>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</div> </div>
) )
} }
export const Route = createFileRoute("/launcher-setup")({ export const Route = createFileRoute("/launcher-setup")({
component: LauncherSetupPage, component: LauncherSetupPage,
}) })