From a912ab279e72c11859478bfe17ea8ad9715930fc Mon Sep 17 00:00:00 2001 From: Joey-nexu Date: Fri, 15 May 2026 16:42:58 +0800 Subject: [PATCH 1/3] fix(ux): consolidate Convert into a single coral chip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Toolbar carried its own Convert/Stop button (and the ⌘/Ctrl+Enter shortcut) while a floating ConvertChip rendered the same action over the editor / preview divider. Two buttons, identical action — confusing. Drop the toolbar pair, leave the chip as the single source of Convert truth, and migrate the keyboard shortcut onto it. Recolor the chip from the previous near-black `var(--ink)` to `var(--coral)` (idle) / `var(--coral-hover)` (running) so the primary CTA is visually distinct from the muted toolbar surface and from text content. Disabled state still renders coral at 0.4 opacity. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/convert-chip.tsx | 36 ++++++++++++++-------- src/components/toolbar.tsx | 53 +-------------------------------- 2 files changed, 25 insertions(+), 64 deletions(-) diff --git a/src/components/convert-chip.tsx b/src/components/convert-chip.tsx index fd68795..14a5703 100644 --- a/src/components/convert-chip.tsx +++ b/src/components/convert-chip.tsx @@ -1,14 +1,14 @@ "use client"; +import { useCallback, useEffect } from "react"; import { useStore, selectActiveTask } from "@/lib/store"; import { useT } from "@/lib/i18n"; import { useConvert } from "@/lib/use-convert"; /** - * Floating chip pinned to the editor / preview divider that runs the same - * Convert action as the toolbar button. Mirrors `Toolbar`'s logic but stays - * close to the editor so users notice it after editing without traveling - * back up to the top bar. Toolbar's own button (and ⌘+Enter shortcut) stay. + * Floating chip pinned to the editor / preview divider — the single Convert + * entry point. The toolbar no longer has its own Convert button (removed to + * avoid duplication), so this chip also owns the ⌘/Ctrl+Enter shortcut. */ export function ConvertChip() { const agent = useStore((s) => s.selectedAgent); @@ -42,14 +42,29 @@ export function ConvertChip() { ? t("toolbar.enterContent") : t("convertChip.tooltip"); - const onClick = () => { + const onClick = useCallback(() => { if (isRunning) { cancel(activeTaskId); return; } if (!canConvert) return; run({ taskId: activeTaskId, agent: agent!, templateId: template, content, format, model }); - }; + }, [isRunning, canConvert, cancel, run, activeTaskId, agent, template, content, format, model]); + + // ⌘/Ctrl + Enter — global shortcut, fires Convert from anywhere on the page. + // Lives here (not in Toolbar) because the chip is the single source of + // Convert truth after the toolbar button was removed. + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { + if (isRunning || !canConvert) return; + e.preventDefault(); + onClick(); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [onClick, isRunning, canConvert]); return (
{isRunning ? ( @@ -82,6 +93,7 @@ export function ConvertChip() { <> {t("convertChip.label")} + ⌘↵ )} diff --git a/src/components/toolbar.tsx b/src/components/toolbar.tsx index 605967b..c2f4c2b 100644 --- a/src/components/toolbar.tsx +++ b/src/components/toolbar.tsx @@ -1,9 +1,7 @@ "use client"; -import { useEffect } from "react"; -import { useStore, selectActiveTask } from "@/lib/store"; +import { useStore } from "@/lib/store"; import { useT } from "@/lib/i18n"; -import { useConvert } from "@/lib/use-convert"; import { TemplatePicker } from "./template-picker"; import { ExportMenu } from "./export-menu"; import { LayoutModeToggle } from "./layout-mode-toggle"; @@ -20,30 +18,10 @@ export function Toolbar({ const agent = useStore((s) => s.selectedAgent); const agents = useStore((s) => s.agents); const agentModels = useStore((s) => s.agentModels); - const activeTaskId = useStore((s) => s.activeTaskId); - const template = useStore((s) => selectActiveTask(s)?.templateId ?? "article-magazine"); - const content = useStore((s) => selectActiveTask(s)?.content ?? ""); - const format = useStore((s) => selectActiveTask(s)?.format ?? "text"); - const status = useStore((s) => selectActiveTask(s)?.status ?? "idle"); - const { run, cancel } = useConvert(); const t = useT(); const agentInfo = agents.find((a) => a.id === agent); const model = agent ? agentModels[agent] ?? "default" : "default"; - const canConvert = - !!agent && !!content.trim() && status !== "running" && !agentInfo?.unsupported; - - useEffect(() => { - const onKey = (e: KeyboardEvent) => { - if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { - e.preventDefault(); - if (canConvert) - run({ taskId: activeTaskId, agent: agent!, templateId: template, content, format, model }); - } - }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - }, [canConvert, agent, template, content, format, model, run, activeTaskId]); return (
- {status === "running" ? ( - - ) : ( - - )}
From 4bfaf1b2ba3806c51eecda2e71d98462eb5da029 Mon Sep 17 00:00:00 2001 From: Joey-nexu Date: Fri, 15 May 2026 16:45:29 +0800 Subject: [PATCH 2/3] fix(home): detect agents on mount so the agent chip restores after reload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The store persists `selectedAgent` across hard reloads but not the `agents[]` list. Today that list is only populated when Settings or Welcome modals open and call `/api/agents`. So a user who reloads without opening either modal sees the toolbar agent chip render the red "Select agent" fallback even though their selection is intact — because `agents.find(a => a.id === selectedAgent)` returns undefined against an empty list. Fire the same `/api/agents` fetch from the home page on mount (after hydration, so it doesn't fight SSR). Failures fall through silently — Settings / Welcome retain their own retry-on-open paths. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/page.tsx | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/app/page.tsx b/src/app/page.tsx index fad9574..2dc7b64 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -8,12 +8,13 @@ import { TasksSidebar } from "@/components/tasks-sidebar"; import { WelcomeModal } from "@/components/welcome-modal"; import { SettingsModal } from "@/components/settings-modal"; import { ConvertChip } from "@/components/convert-chip"; -import { useStore } from "@/lib/store"; +import { useStore, type AgentInfo } from "@/lib/store"; export default function Home() { const iframeRef = useRef(null); const welcomeAck = useStore((s) => s.welcomeAck); const selectedAgent = useStore((s) => s.selectedAgent); + const setAgents = useStore((s) => s.setAgents); const locale = useStore((s) => s.locale); const layoutMode = useStore((s) => s.layoutMode); const [welcomeOpen, setWelcomeOpen] = useState(false); @@ -24,6 +25,29 @@ export default function Home() { setHydrated(true); }, []); + // Detect agents on mount so the toolbar's agent chip can resolve the + // persisted `selectedAgent` to a label without waiting for the user to + // open Settings or Welcome. Without this, after a hard reload the chip + // briefly (or permanently) shows "Select agent" even though selection + // is intact in localStorage. + useEffect(() => { + if (!hydrated) return; + let cancelled = false; + (async () => { + try { + const res = await fetch("/api/agents", { cache: "no-store" }); + if (!res.ok) return; + const data = (await res.json()) as { agents: AgentInfo[] }; + if (!cancelled) setAgents(data.agents); + } catch { + // Settings / Welcome modals will retry on open. + } + })(); + return () => { + cancelled = true; + }; + }, [hydrated, setAgents]); + // Keep in sync with the user's locale so screen readers // and browser features (autotranslate, hyphenation) pick the right language. useEffect(() => { From ac78a51b361dfc3ea407d0357a1dbea86477b437 Mon Sep 17 00:00:00 2001 From: Joey-nexu Date: Fri, 15 May 2026 16:45:29 +0800 Subject: [PATCH 3/3] fix(agents): use shell on win32 in resolveOpenclawAgentId (#16 follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #17 fixed the win32 spawn EINVAL in `invokeAgent` but missed the matching `spawn` inside `resolveOpenclawAgentId`. The same root cause applies — npm-installed openclaw on Windows is a `.cmd` shim that Node cannot launch directly without `shell: true`. Same Unix behavior, no shell-injection surface (argv is a fixed `["agents", "list"]`, not user input). Closes #16 (the second of two locations identified in the original report). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/agents/detect.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/lib/agents/detect.ts b/src/lib/agents/detect.ts index 8e4fd77..c72c8db 100644 --- a/src/lib/agents/detect.ts +++ b/src/lib/agents/detect.ts @@ -360,7 +360,10 @@ export async function resolveOpenclawAgentId(bin: string): Promise { try { const { spawn } = await import("node:child_process"); const out = await new Promise((res, rej) => { - const child = spawn(bin, ["agents", "list"], { stdio: ["ignore", "pipe", "pipe"] }); + const child = spawn(bin, ["agents", "list"], { + stdio: ["ignore", "pipe", "pipe"], + shell: process.platform === "win32", + }); let buf = ""; child.stdout.setEncoding("utf8"); child.stdout.on("data", (c) => (buf += c));