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
10 changed files with 184 additions and 161 deletions
+5 -4
View File
@@ -41,9 +41,7 @@ export async function postLauncherDashboardLogout(): Promise<boolean> {
return res.ok
}
export type SetupResult =
| { ok: true }
| { ok: false; error: string }
export type SetupResult = { ok: true } | { ok: false; error: string }
export async function postLauncherDashboardSetup(
password: string,
@@ -53,7 +51,10 @@ export async function postLauncherDashboardSetup(
method: "POST",
headers: { "Content-Type": "application/json" },
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 }
let msg = "Unknown error"
+24 -9
View File
@@ -14,6 +14,7 @@ import { Link } from "@tanstack/react-router"
import * as React from "react"
import { useTranslation } from "react-i18next"
import { postLauncherDashboardLogout } from "@/api/launcher-auth"
import {
AlertDialog,
AlertDialogAction,
@@ -40,7 +41,6 @@ import {
} from "@/components/ui/tooltip"
import { useGateway } from "@/hooks/use-gateway.ts"
import { useTheme } from "@/hooks/use-theme.ts"
import { postLauncherDashboardLogout } from "@/api/launcher-auth"
export function AppHeader() {
const { i18n, t } = useTranslation()
@@ -198,27 +198,42 @@ export function AppHeader() {
<IconPower className="h-4 w-4 opacity-80" />
</Button>
</TooltipTrigger>
<TooltipContent>{gwError ?? t("header.gateway.action.stop")}</TooltipContent>
<TooltipContent>
{gwError ?? t("header.gateway.action.stop")}
</TooltipContent>
</Tooltip>
) : (
<Tooltip delayDuration={(gwError || (!canStart && startReason)) ? 0 : 700}>
<Tooltip
delayDuration={gwError || (!canStart && startReason) ? 0 : 700}
>
<TooltipTrigger asChild>
{/* Wrap in span so the tooltip still fires when the button is disabled */}
<span
className={!canStart && startReason ? "cursor-not-allowed" : undefined}
className={
!canStart && startReason ? "cursor-not-allowed" : undefined
}
tabIndex={!canStart && startReason ? 0 : undefined}
>
<Button
variant={
isStarting || isRestarting || isStopping ? "secondary" : "default"
isStarting || isRestarting || isStopping
? "secondary"
: "default"
}
size="sm"
data-tour="gateway-button"
className={`h-8 gap-2 px-3 ${isStopped ? "bg-green-500 text-white hover:bg-green-600" : ""
} ${!canStart ? "pointer-events-none" : ""}`}
className={`h-8 gap-2 px-3 ${
isStopped
? "bg-green-500 text-white hover:bg-green-600"
: ""
} ${!canStart ? "pointer-events-none" : ""}`}
onClick={handleGatewayToggle}
disabled={
gwLoading || isStarting || isRestarting || isStopping || !canStart
gwLoading ||
isStarting ||
isRestarting ||
isStopping ||
!canStart
}
>
{gwLoading || isStarting || isRestarting || isStopping ? (
@@ -238,7 +253,7 @@ export function AppHeader() {
</Button>
</span>
</TooltipTrigger>
{(gwError || (!canStart && startReason)) ? (
{gwError || (!canStart && startReason) ? (
<TooltipContent>{gwError ?? startReason}</TooltipContent>
) : null}
</Tooltip>
@@ -42,15 +42,11 @@ export function ChatComposer({
}: ChatComposerProps) {
const { t } = useTranslation()
const canInput = inputDisabledReason === null
const placeholder = canInput
? t("chat.placeholder")
: t(`chat.disabledPlaceholder.${inputDisabledReason}`)
const inputDisabledReason = (() => {
if (!isConnected) return t("chat.inputDisabled.notConnected")
if (!hasDefaultModel) return t("chat.inputDisabled.noModel")
return null
})()
const disabledMessage =
inputDisabledReason === null
? null
: t(`chat.disabledPlaceholder.${inputDisabledReason}`)
const placeholder = disabledMessage ?? t("chat.placeholder")
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.nativeEvent.isComposing) return
@@ -95,7 +91,7 @@ export function ChatComposer({
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={!canInput}
title={inputDisabledReason || undefined}
title={disabledMessage || undefined}
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",
!canInput && "cursor-not-allowed",
@@ -103,9 +99,9 @@ export function ChatComposer({
minRows={1}
maxRows={8}
/>
{!canInput && inputDisabledReason && (
<div className="px-3 py-1 text-xs text-muted-foreground">
{inputDisabledReason}
{!canInput && disabledMessage && (
<div className="text-muted-foreground px-3 py-1 text-xs">
{disabledMessage}
</div>
)}
@@ -5,8 +5,8 @@ import { toast } from "sonner"
import { AssistantMessage } from "@/components/chat/assistant-message"
import {
type ChatInputDisabledReason,
ChatComposer,
type ChatInputDisabledReason,
} from "@/components/chat/chat-composer"
import { ChatEmptyState } from "@/components/chat/chat-empty-state"
import { ModelSelector } from "@/components/chat/model-selector"
+1 -4
View File
@@ -12,10 +12,7 @@ import {
generateSessionId,
readStoredSessionId,
} from "@/features/chat/state"
import {
invalidateSocket,
isCurrentSocket,
} from "@/features/chat/websocket"
import { invalidateSocket, isCurrentSocket } from "@/features/chat/websocket"
import i18n from "@/i18n"
import {
type ChatAttachment,
+1 -4
View File
@@ -1,10 +1,7 @@
import { toast } from "sonner"
import { normalizeUnixTimestamp } from "@/features/chat/state"
import {
type AssistantMessageKind,
updateChatStore,
} from "@/store/chat"
import { type AssistantMessageKind, updateChatStore } from "@/store/chat"
export interface PicoMessage {
type: string
+11 -1
View File
@@ -77,5 +77,15 @@ export function useGateway() {
}
}, [state])
return { state, loading, canStart, startReason, restartRequired, start, stop, restart, error }
return {
state,
loading,
canStart,
startReason,
restartRequired,
start,
stop,
restart,
error,
}
}
+3 -1
View File
@@ -53,7 +53,9 @@ const RootLayout = () => {
globalThis.location.assign("/launcher-login")
} else {
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.",
)
}
})
+7 -2
View File
@@ -3,7 +3,10 @@ import { createFileRoute } from "@tanstack/react-router"
import * as React from "react"
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 {
Card,
@@ -37,7 +40,9 @@ function LauncherLoginPage() {
globalThis.location.assign("/launcher-setup")
}
})
.catch(() => { /* network error — stay on login page */ })
.catch(() => {
/* network error — stay on login page */
})
}, [])
const loginWithToken = React.useCallback(
+122 -122
View File
@@ -6,141 +6,141 @@ import { useTranslation } from "react-i18next"
import { postLauncherDashboardSetup } from "@/api/launcher-auth"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { useTheme } from "@/hooks/use-theme"
function LauncherSetupPage() {
const { t, i18n } = useTranslation()
const { theme, toggleTheme } = useTheme()
const [password, setPassword] = React.useState("")
const [confirm, setConfirm] = React.useState("")
const [submitting, setSubmitting] = React.useState(false)
const [error, setError] = React.useState("")
const { t, i18n } = useTranslation()
const { theme, toggleTheme } = useTheme()
const [password, setPassword] = React.useState("")
const [confirm, setConfirm] = React.useState("")
const [submitting, setSubmitting] = React.useState(false)
const [error, setError] = React.useState("")
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setError("")
if (password !== confirm) {
setError(t("launcherSetup.errorMismatch"))
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)
}
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setError("")
if (password !== confirm) {
setError(t("launcherSetup.errorMismatch"))
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)
}
}
return (
<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">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" aria-label="Language">
<IconLanguage className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => i18n.changeLanguage("en")}>
English
</DropdownMenuItem>
<DropdownMenuItem onClick={() => i18n.changeLanguage("zh")}>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="outline"
size="icon"
type="button"
onClick={() => toggleTheme()}
aria-label={theme === "dark" ? "Light mode" : "Dark mode"}
>
{theme === "dark" ? (
<IconSun className="size-4" />
) : (
<IconMoon className="size-4" />
)}
</Button>
</header>
return (
<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">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" aria-label="Language">
<IconLanguage className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => i18n.changeLanguage("en")}>
English
</DropdownMenuItem>
<DropdownMenuItem onClick={() => i18n.changeLanguage("zh")}>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="outline"
size="icon"
type="button"
onClick={() => toggleTheme()}
aria-label={theme === "dark" ? "Light mode" : "Dark mode"}
>
{theme === "dark" ? (
<IconSun className="size-4" />
) : (
<IconMoon className="size-4" />
)}
</Button>
</header>
<div className="flex flex-1 items-center justify-center p-4">
<Card className="w-full max-w-md" size="sm">
<CardHeader>
<CardTitle>{t("launcherSetup.title")}</CardTitle>
<CardDescription>{t("launcherSetup.description")}</CardDescription>
</CardHeader>
<CardContent>
<form className="flex flex-col gap-4" onSubmit={onSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="setup-password">
{t("launcherSetup.passwordLabel")}
</Label>
<Input
id="setup-password"
name="password"
type="password"
autoComplete="new-password"
required
minLength={8}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t("launcherSetup.passwordPlaceholder")}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="setup-confirm">
{t("launcherSetup.confirmLabel")}
</Label>
<Input
id="setup-confirm"
name="confirm"
type="password"
autoComplete="new-password"
required
minLength={8}
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
placeholder={t("launcherSetup.confirmPlaceholder")}
/>
</div>
<Button type="submit" disabled={submitting}>
{submitting ? t("labels.loading") : t("launcherSetup.submit")}
</Button>
{error ? (
<p className="text-destructive text-sm" role="alert">
{error}
</p>
) : null}
</form>
</CardContent>
</Card>
</div>
</div>
)
<div className="flex flex-1 items-center justify-center p-4">
<Card className="w-full max-w-md" size="sm">
<CardHeader>
<CardTitle>{t("launcherSetup.title")}</CardTitle>
<CardDescription>{t("launcherSetup.description")}</CardDescription>
</CardHeader>
<CardContent>
<form className="flex flex-col gap-4" onSubmit={onSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="setup-password">
{t("launcherSetup.passwordLabel")}
</Label>
<Input
id="setup-password"
name="password"
type="password"
autoComplete="new-password"
required
minLength={8}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t("launcherSetup.passwordPlaceholder")}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="setup-confirm">
{t("launcherSetup.confirmLabel")}
</Label>
<Input
id="setup-confirm"
name="confirm"
type="password"
autoComplete="new-password"
required
minLength={8}
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
placeholder={t("launcherSetup.confirmPlaceholder")}
/>
</div>
<Button type="submit" disabled={submitting}>
{submitting ? t("labels.loading") : t("launcherSetup.submit")}
</Button>
{error ? (
<p className="text-destructive text-sm" role="alert">
{error}
</p>
) : null}
</form>
</CardContent>
</Card>
</div>
</div>
)
}
export const Route = createFileRoute("/launcher-setup")({
component: LauncherSetupPage,
component: LauncherSetupPage,
})