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 ${
isStopped
? "bg-green-500 text-white hover:bg-green-600"
: ""
} ${!canStart ? "pointer-events-none" : ""}`} } ${!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
? null
: t(`chat.disabledPlaceholder.${inputDisabledReason}`) : t(`chat.disabledPlaceholder.${inputDisabledReason}`)
const placeholder = disabledMessage ?? t("chat.placeholder")
const inputDisabledReason = (() => {
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(