diff --git a/packages/app/src/screens/chat/desktop-chat.test.ts b/packages/app/src/screens/chat/desktop-chat.test.ts new file mode 100644 index 00000000..f47286b9 --- /dev/null +++ b/packages/app/src/screens/chat/desktop-chat.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { vi } from "vitest"; + +vi.mock("../../api", () => ({ api: {} })); +vi.mock("../../shell/nav", () => ({ useNav: () => ({ params: new URLSearchParams(), route: "home" }) })); +vi.mock("../../state/live", () => ({ useQuery: () => ({ state: "ready", data: { mode: "local" } }) })); +vi.mock("../../i18n", () => ({ useT: () => (key: string) => key })); +vi.mock("../../preview", () => ({ isPreview: () => false })); +vi.mock("./live-chat", () => ({ Home: () => null })); +vi.mock("./voice", () => ({ Voice: () => null, VOICE_VARIANTS: [] })); +vi.mock("./canvas", () => ({ Canvas: () => null, CANVAS_VARIANTS: [] })); +vi.mock("./research", () => ({ SearchResults: () => null, DeepResearch: () => null, SEARCH_VARIANTS: [], DEEP_VARIANTS: [] })); +vi.mock("./extras", () => ({ ImageGen: () => null, Share: () => null, TempChat: () => null, IMAGE_VARIANTS: [], SHARE_VARIANTS: [], TEMP_VARIANTS: [] })); +vi.mock("./pages", () => ({ History: () => null, Library: () => null })); +vi.mock("../../registry", () => ({ Root: ({ children }: { children: React.ReactNode }) => createElement("div", null, children) })); +import { SCREENS } from "./index"; + +describe("desktop Chat registration", () => { + it("keeps conversation and state samples out of discovery", () => { + expect(SCREENS.map(s => s.id)).not.toContain("chat"); + expect(SCREENS.map(s => s.id)).not.toContain("chat-states"); + }); + it.each(["canvas", "search-results", "deep-research", "image-gen", "share", "temp-chat"])("%s direct route shares the local capability state", id => { + const screen = SCREENS.find(s => s.id === id)!; + const html = renderToStaticMarkup(screen.render()); + expect(html).toContain('data-testid="chat-capability-empty"'); + expect(html).toContain(" api.connection.get(), []); + if (isPreview()) return children; + if (connection.state === "loading") return
{t("chat.live.loading")}
; + if (connection.state === "error") return
{t("chat.err.network.body")}
; + if (connection.data.mode !== "local") return children; + return
+

{t(`chat.screen.${feature}`)}

{t("chat.unavailable.body")}

+ +
; +} const G = "chat.group"; export const SCREENS: ScreenDef[] = [ { id: "home", name: "chat.screen.home", mode: "Cortex", group: G, render: () => }, - { id: "chat", name: "chat.screen.chat", mode: "Cortex", group: G, render: () => }, - { id: "chat-states", name: "chat.screen.chat-states", mode: "Cortex", group: G, variants: CHAT_STATE_VARIANTS, render: () => }, { id: "voice", name: "chat.screen.voice", mode: "Cortex", group: G, variants: VOICE_VARIANTS, render: () => }, - { id: "canvas", name: "chat.screen.canvas", mode: "Cortex", group: G, variants: CANVAS_VARIANTS, render: () => }, - { id: "search-results", name: "chat.screen.search-results", mode: "Cortex", group: G, variants: SEARCH_VARIANTS, render: () => }, - { id: "deep-research", name: "chat.screen.deep-research", mode: "Cortex", group: G, variants: DEEP_VARIANTS, render: () => }, - { id: "image-gen", name: "chat.screen.image-gen", mode: "Cortex", group: G, variants: IMAGE_VARIANTS, render: () => }, - { id: "share", name: "chat.screen.share", mode: "Cortex", group: G, variants: SHARE_VARIANTS, render: () => }, - { id: "temp-chat", name: "chat.screen.temp-chat", mode: "Cortex", group: G, variants: TEMP_VARIANTS, render: () => }, + { id: "canvas", name: "chat.screen.canvas", mode: "Cortex", group: G, variants: CANVAS_VARIANTS, render: () => }, + { id: "search-results", name: "chat.screen.search-results", mode: "Cortex", group: G, variants: SEARCH_VARIANTS, render: () => }, + { id: "deep-research", name: "chat.screen.deep-research", mode: "Cortex", group: G, variants: DEEP_VARIANTS, render: () => }, + { id: "image-gen", name: "chat.screen.image-gen", mode: "Cortex", group: G, variants: IMAGE_VARIANTS, render: () => }, + { id: "share", name: "chat.screen.share", mode: "Cortex", group: G, variants: SHARE_VARIANTS, render: () => }, + { id: "temp-chat", name: "chat.screen.temp-chat", mode: "Cortex", group: G, variants: TEMP_VARIANTS, render: () => }, { id: "history", name: "chat.screen.history", mode: "Cortex", group: G, render: () => }, { id: "library", name: "chat.screen.library", mode: "Cortex", group: G, render: () => }, ]; diff --git a/packages/app/src/screens/chat/live-chat.tsx b/packages/app/src/screens/chat/live-chat.tsx index b9149955..b20b68a4 100644 --- a/packages/app/src/screens/chat/live-chat.tsx +++ b/packages/app/src/screens/chat/live-chat.tsx @@ -1,4 +1,4 @@ -// Home and conversation (design "home", "chat"): live engine path plus the preview transcript. +// Home and conversation: live engine path plus the preview transcript. import * as React from "react"; import { Collapsible } from "@base-ui/react/collapsible"; import type { MessageWithParts, Part, ToolPart, FilePart, PromptPartInput } from "@cortex/schema"; @@ -20,42 +20,36 @@ import { RemoteBoundary } from "./remote-chat"; const KNOWN_ERRORS = ["model_no_image_input", "model_no_pdf_input", "context_window_exceeded", "model_not_found", "provider_key_missing", "provider_auth_failed", "provider_rate_limited", "provider_error", "provider_disabled", "provider_unsupported", "session_busy", "aborted", "tool_failed", "permission_rejected", "permission_denied", "catalog_unavailable", "network"]; -/** Maps an engine error code to Cortex copy. Raw messages and vendor names never reach the screen. */ const errKey = (code?: string) => (code && KNOWN_ERRORS.includes(code) ? code : "generic"); const PROVIDER_ERRORS = ["provider_key_missing", "provider_auth_failed", "provider_disabled", "provider_unsupported", "model_not_found"]; - const toParts = (text: string, atts: ComposerAttachment[]): PromptPartInput[] => [{ type: "text", text }, ...atts.map((a): PromptPartInput => ({ type: "file", mime: a.mime, filename: a.name, url: a.dataUrl }))]; function NoProvider() { const t = useT(); const { go } = useNav(); - return ( -
- {t("chat.noProvider.title")}{t("chat.noProvider.body")} - -
- ); + return
+ {t("chat.noProvider.title")}{t("chat.noProvider.body")} + +
; } function ErrorCard({ code, onRetry }: { code?: string; onRetry?: () => void }) { const t = useT(); const { go } = useNav(); const k = errKey(code); - return ( -
- -
{code === "not_found" ? t("shell.notFound.title") : t(`chat.err.${k}.title`)}{code === "not_found" ? t("shell.notFound.body") : t(`chat.err.${k}.body`)}
- {code && PROVIDER_ERRORS.includes(code) - ? - : onRetry && } -
- ); + return
+ +
{code === "not_found" ? t("shell.notFound.title") : t(`chat.err.${k}.title`)}{code === "not_found" ? t("shell.notFound.body") : t(`chat.err.${k}.body`)}
+ {code && PROVIDER_ERRORS.includes(code) + ? + : onRetry && } +
; } -/* ---------------------------------------------------------------- Home */ export function Home() { const { params, entryKey } = useNav(); + if (params.has("id")) return ; if (isPreview() || params.has("project")) return ; return } />; } @@ -75,10 +69,6 @@ function LocalHome() { const pending = React.useRef(false); const owner = React.useRef(false); React.useLayoutEffect(() => { owner.current = true; return () => { owner.current = false; }; }, []); - const refresh = () => { - if (!preview) models.reload(); - toast.add({ title: t("chat.home.refreshed"), description: t(preview ? "chat.home.refreshedPreview" : "chat.home.draftKept") }); - }; const send = async (text: string, atts: ComposerAttachment[], o: SendOptions) => { if (!owner.current || pending.current) return false; pending.current = true; setSending(true); @@ -91,7 +81,7 @@ function LocalHome() { if (!owner.current) return false; await api.sessions.prompt(sessionID.current, { parts: toParts(text, atts), model: o.model, reasoning: o.reasoning, expectedProjectID: projectID ?? null }); if (!owner.current) return false; - go("chat", { id: sessionID.current }); + go("home", { id: sessionID.current }); return true; } catch (e) { const k = errKey((e as { code?: string })?.code); @@ -99,21 +89,17 @@ function LocalHome() { return false; } finally { pending.current = false; if (owner.current) setSending(false); } }; - return (<> -
go("home")} />
-
-

{t("chat.home.title")}

- {!preview && (noModel || models.state === "error") && } - {preview ? startPreviewChat("chat", text, model)} onModelChange={setPreviewModel} /> - : setNoModel(true)} onSend={send} />} - {preview &&
- {(fx.home.suggestions as string[][]).map(([g, s], i) => )} -
} -
- ); + return
+

{t("chat.home.title")}

+ {!preview && (noModel || models.state === "error") && } + {preview ? startPreviewChat("chat", text, model)} onModelChange={setPreviewModel} /> + : { if (sessionID.current) void api.sessions.abort(sessionID.current); }} onNoModel={() => setNoModel(true)} onSend={send} />} + {preview &&
+ {(fx.home.suggestions as string[][]).map(([g, s], i) => )} +
} +
; } -/* ---------------------------------------------------------------- Chat */ export function Chat() { const { params, go, entryKey } = useNav(); const id = params.get("id"); @@ -138,8 +124,7 @@ function PreviewChat({ start }: { start: ReturnType }) const [pinned, setPinned] = React.useState(false); const [deleted, setDeleted] = React.useState(false); const [msgs, setMsgs] = React.useState<{ who: "u" | "b"; text: string; code?: boolean; model?: string; prompt?: string }[]>(start ? [{ who: "u", text: start.text }] : [ - { who: "u", text: fx.q }, - { who: "b", text: (fx.reply as string[]).join("\n"), code: true }, + { who: "u", text: fx.q }, { who: "b", text: (fx.reply as string[]).join("\n"), code: true }, ]); // ponytail: preview-only responses; live conversations use the engine stream below. const [queue, setQueue] = React.useState<{ text: string; model: string; replace?: number }[]>(start ? [start] : []); @@ -163,7 +148,7 @@ function PreviewChat({ start }: { start: ReturnType }) const send = (text: string, _: ComposerAttachment[], model: string) => { setDeleted(false); setMsgs((m) => [...m, { who: "u", text }]); setQueue((q) => [...q, { text, model }]); }; - return (<> + return <>
{editing ?
{ e.preventDefault(); const value = String(new FormData(e.currentTarget).get("value") ?? "").trim(); @@ -187,10 +172,7 @@ function PreviewChat({ start }: { start: ReturnType }) ?
{m.text}
:
{m.text.split("\n").map((p, j) =>

{p}

)} - {m.code && <> -

{fx.h}

-
{fx.file} copy(fx.csv, t("chat.preview.planningCopied"))} />
{fx.csv}
- } + {m.code && <>

{fx.h}

{fx.file} copy(fx.csv, t("chat.preview.planningCopied"))} />
{fx.csv}
}
copy(m.text, t("chat.toast.answerCopied"))} /> setQueue((q) => [...q, { text: m.prompt ?? msgs.slice(0, i).findLast((x) => x.who === "u")?.text ?? title, model: m.model ?? start?.model ?? t("composer.model.fast"), replace: i }])} /> copy(`${title}\n\n${m.text}`, t("chat.preview.shareCopied"))} /> @@ -201,7 +183,7 @@ function PreviewChat({ start }: { start: ReturnType })
{typing && }{t(start ? "chat.preview.hint" : "chat.hint")}
- ); + ; } const textOf = (m: MessageWithParts) => m.parts.filter((p): p is Extract => p.type === "text").map((p) => p.text).join("\n"); @@ -229,17 +211,13 @@ function LiveReasoning({ text, live, secs }: { text: string; live: boolean; secs const t = useT(); const bot = useBotCfg(); const steps = text.split("\n").map((s) => s.trim()).filter(Boolean); - return ( - - - {live ? {t("chat.thinkingName", { name: bot.name })} : secs !== undefined ? {t("chat.thoughtFor", { secs })} : {t("chat.thought")}} - - - -
    {steps.map((s, i) =>
  1. {s}
  2. )}
-
-
- ); + return + + {live ? {t("chat.thinkingName", { name: bot.name })} : secs !== undefined ? {t("chat.thoughtFor", { secs })} : {t("chat.thought")}} + + +
    {steps.map((s, i) =>
  1. {s}
  2. )}
+
; } function ToolBlock({ p }: { p: ToolPart }) { @@ -247,36 +225,26 @@ function ToolBlock({ p }: { p: ToolPart }) { const s = p.state; const title = toolTitle(t, p); const done = s.status === "completed"; - return ( -
- -
-
- {s.status === "pending" || s.status === "running" ? {title} : {title}} - {done ? {t("chat.tool.ok")} - : s.status === "error" ? {t("chat.tool.failed")} - : } -
-
{toolName(t, p.tool)}
-
-
- ); + return
+ +
+ {s.status === "pending" || s.status === "running" ? {title} : {title}} + {done ? {t("chat.tool.ok")} : s.status === "error" ? {t("chat.tool.failed")} : } +
{toolName(t, p.tool)}
+
; } function PermissionCard({ id, tool, input }: { id: string; tool: string; input: string }) { const t = useT(); const [busy, setBusy] = React.useState(false); const reply = (r: "once" | "always" | "reject") => { setBusy(true); api.permissions.reply(id, r).catch(() => setBusy(false)); }; - return ( -
- - {t("chat.perm.title", { tool: toolName(t, tool) })} - {input} - - - -
- ); + return
+ {t("chat.perm.title", { tool: toolName(t, tool) })} + {input} + + + +
; } function LiveChat({ id }: { id: string }) { @@ -299,9 +267,10 @@ function LiveChat({ id }: { id: string }) { const composer = React.useRef(null); const opening = React.useRef(false); const [leaving, setLeaving] = React.useState(false); + const [sendError, setSendError] = React.useState(); const atChat = React.useCallback(() => { const hash = readHash(); - return hash.route === "chat" && !hash.params.has("preview") && !hash.params.has("shot") && hash.params.getAll("id").length === 1 && hash.params.get("id") === id; + return ["home", "chat"].includes(hash.route) && !hash.params.has("preview") && !hash.params.has("shot") && hash.params.getAll("id").length === 1 && hash.params.get("id") === id; }, [id]); React.useLayoutEffect(() => { const sync = () => { if (opening.current && atChat()) { opening.current = false; setLeaving(false); composer.current?.resume(); } }; @@ -317,7 +286,7 @@ function LiveChat({ id }: { id: string }) { const asks = perms.state === "ready" ? perms.data.filter((p) => p.sessionID === id) : []; const attachmentBlocked = busy || renaming || renameBusy || !!projectDraft || projectBusy; const attachment = (p: FilePart, m: MessageWithParts) => { - const valid = route === "chat" && !params.has("preview") && !params.has("shot") && params.getAll("id").length === 1 + const valid = ["home", "chat"].includes(route) && !params.has("preview") && !params.has("shot") && params.getAll("id").length === 1 && session.state === "ready" && session.data.id === id && session.data.kind === "chat" && m.info.sessionID === id && p.sessionID === id && p.messageID === m.info.id && /^ses_[0-9a-f]{32,}$/.test(id) && /^msg_[0-9a-f]{32,}$/.test(m.info.id) && /^prt_[0-9a-f]{32,}$/.test(p.id); @@ -332,23 +301,23 @@ function LiveChat({ id }: { id: string }) { } : undefined} />; }; React.useEffect(() => { end.current?.scrollIntoView({ block: "end" }); }, [msgs, asks.length]); - const title = session.state === "ready" ? session.data.title || t("chat.untitled") : ""; - const prompt = (parts: PromptPartInput[], o?: SendOptions) => - opening.current ? Promise.resolve(false) : api.sessions.prompt(id, { parts, model: o?.model, reasoning: o?.reasoning, ...(session.state === "ready" && !session.data.parentID ? { expectedProjectID: session.data.projectID ?? null } : {}) }).then(() => true, (e: { code?: string }) => { - const k = errKey(e?.code); - toast.add({ title: t(`chat.err.${k}.title`), description: t(`chat.err.${k}.body`), data: { icon: "alert-triangle" } }); + const prompt = (parts: PromptPartInput[], o?: SendOptions) => { + setSendError(undefined); + return opening.current ? Promise.resolve(false) : api.sessions.prompt(id, { parts, model: o?.model, reasoning: o?.reasoning, ...(session.state === "ready" && !session.data.parentID ? { expectedProjectID: session.data.projectID ?? null } : {}) }).then(() => true, (e: { code?: string }) => { + if (owner.current) setSendError(errKey(e?.code)); return false; }); + }; const retry = (before: number) => { const u = msgs.slice(0, before).reverse().find((m) => m.info.role === "user"); - if (u) prompt(u.parts.filter((p): p is Extract => p.type === "text" || p.type === "file")); + if (u) void prompt(u.parts.filter((p): p is Extract => p.type === "text" || p.type === "file")); }; const remove = () => { if (opening.current) return; let undone = false; - toast.add({ title: t("chat.toast.deleted"), description: title, data: { undo: true, icon: "trash", onUndo: () => { undone = true; go("chat", { id }); } }, - onClose: () => { if (!undone) api.sessions.delete(id).catch(() => { if (readHash().route === "home") go("chat", { id }); toast.add({ title: t("chat.err.generic.title"), data: { icon: "alert-triangle" } }); }); } }); + toast.add({ title: t("chat.toast.deleted"), description: title, data: { undo: true, icon: "trash", onUndo: () => { undone = true; go("home", { id }); } }, + onClose: () => { if (!undone) api.sessions.delete(id).catch(() => { if (readHash().route === "home") go("home", { id }); toast.add({ title: t("chat.err.generic.title"), data: { icon: "alert-triangle" } }); }); } }); go("home"); }; const movable = session.state === "ready" && session.data.kind === "chat" && !session.data.parentID; @@ -365,8 +334,7 @@ function LiveChat({ id }: { id: string }) { if (owner.current) toast.add({ title: t(`chat.err.${k}.title`), description: k === "session_busy" ? t(`chat.err.${k}.body`) : undefined, data: { icon: "alert-triangle" } }); } finally { projectWrite.current = false; if (owner.current) setProjectBusy(false); } }; - - return (<> + return <>
{projectDraft ? { e.preventDefault(); void move(); }}> { if (e.key === "Enter") e.currentTarget.blur(); if (e.key === "Escape") setRenaming(false); }} - onBlur={(e) => { const v = e.currentTarget.value.trim(); if (v && v !== title) { renameWrite.current = true; setRenameBusy(true); api.sessions.update(id, { title: v }).then(session.reload, () => {}).finally(() => { renameWrite.current = false; if (owner.current) setRenameBusy(false); }); } setRenaming(false); }} /> + ? { if (e.key === "Enter") e.currentTarget.blur(); if (e.key === "Escape") setRenaming(false); }} + onBlur={(e) => { const v = e.currentTarget.value.trim(); if (v && v !== title) { renameWrite.current = true; setRenameBusy(true); api.sessions.update(id, { title: v }).then(session.reload, () => {}).finally(() => { renameWrite.current = false; if (owner.current) setRenameBusy(false); }); } setRenaming(false); }} /> : {title}} }> { if (!opening.current && !renameWrite.current) { setProjectDraft(null); setRenaming(true); } }}>{t("chat.menu.rename")} {movable && { if (!opening.current) { setRenaming(false); setProjectDraft({ value: session.data.projectID ?? "" }); projects.reload(); } }}>{t("chat.menu.move")}} {t("chat.menu.delete")} -
{ if (!opening.current) go("home", session.state === "ready" && session.data.projectID ? { project: session.data.projectID } : undefined); }} />
{session.state === "error" && } {projectDraft && projects.state === "loading" &&
{t("system.variant.loading")}
} {projectDraft && projects.state === "error" && } + {sendError && !msgs.some((m) => m.info.error && errKey(m.info.error.code) === sendError) && } {msgs.map((m, mi) => { if (m.info.role === "user") { const files = m.parts.filter((p): p is FilePart => p.type === "file"); const text = textOf(m); - return ( -
- {files.length > 0 &&
{files.map((p) => attachment(p, m))}
} - {text &&
{text}
} -
- ); + return
{files.length > 0 &&
{files.map((p) => attachment(p, m))}
}{text &&
{text}
}
; } const isLast = mi === msgs.length - 1; const streaming = isLast && busy; const secs = m.info.time.completed ? Math.max(1, Math.round((m.info.time.completed - m.info.time.created) / 1000)) : undefined; const visible = m.parts.filter((p) => p.type === "text" || p.type === "reasoning" || p.type === "tool" || p.type === "file"); const lastText = [...visible].reverse().find((p) => p.type === "text"); - return ( - - {visible.length === 0 && streaming && {t("chat.thinking")}} - {visible.map((p) => { - if (p.type === "reasoning") return ; - if (p.type === "tool") return ; - if (p.type === "file") return attachment(p, m); - if (p.type === "text") return ; - return null; - })} - {m.info.error && m.info.error.code !== "aborted" && retry(mi)} />} - {m.info.error?.code === "aborted" &&
{t("chat.stopped")}
} - {!streaming && !m.info.error && retry(mi)} />} -
- ); + return + {visible.length === 0 && streaming && {t("chat.thinking")}} + {visible.map((p) => { + if (p.type === "reasoning") return ; + if (p.type === "tool") return ; + if (p.type === "file") return attachment(p, m); + if (p.type === "text") return ; + return null; + })} + {m.info.error && m.info.error.code !== "aborted" && retry(mi)} />} + {m.info.error?.code === "aborted" &&
{t("chat.stopped")}
} + {!streaming && !m.info.error && retry(mi)} />} +
; })} {last?.info.role === "user" && busy && {t("chat.thinking")}} {asks.map((p) => )} @@ -430,9 +390,8 @@ function LiveChat({ id }: { id: string }) {
{(noModel || models.state === "error") && } - api.sessions.abort(id).catch(() => {})} onNoModel={() => setNoModel(true)} - onSend={(text, atts, o) => prompt(toParts(text, atts), o)} /> + api.sessions.abort(id).catch(() => {})} onNoModel={() => setNoModel(true)} onSend={(text, atts, o) => prompt(toParts(text, atts), o)} /> {t("chat.hint")}
- ); + ; } diff --git a/packages/app/src/screens/chat/model-composer.tsx b/packages/app/src/screens/chat/model-composer.tsx index da18f5df..132e8424 100644 --- a/packages/app/src/screens/chat/model-composer.tsx +++ b/packages/app/src/screens/chat/model-composer.tsx @@ -1,5 +1,4 @@ -// Live composer: same markup as components/composer.tsx, with the configured models, -// capability badges, the thinking toggle and real file attachments. +// Live composer: configured models, capability badges and real attachments. import * as React from "react"; import { Menu } from "@base-ui/react/menu"; import type { ModelInfo, ModelRef } from "@cortex/schema"; @@ -10,7 +9,6 @@ import { api } from "../../api"; import { useQuery } from "../../state/live"; import { useNav } from "../../shell/nav"; import { useI18n } from "../../i18n"; -import { StopBtn } from "./shared"; import { useSendEnter } from "../../state/send-enter"; const MODEL_KEY = "cortex.model"; @@ -42,11 +40,27 @@ const readFile = (f: File) => new Promise((ok, ko) => { r.readAsDataURL(f); }); +/** Time is the behavior: mount only for the active run, clean up on settlement. */ +export function BusyStrip({ onStop }: { onStop?: () => void }) { + const { t } = useI18n(); + const [seconds, setSeconds] = React.useState(0); + React.useEffect(() => { + const start = Date.now(); + const timer = setInterval(() => setSeconds(Math.floor((Date.now() - start) / 1000)), 250); + return () => clearInterval(timer); + }, []); + return
+ {t("chat.thinking")} + + + {onStop && } +
; +} + type Props = ModelOptions & { placeholder?: string; inputTestId?: string; onSend: (text: string, attachments: ComposerAttachment[], opts: SendOptions) => Promise; - /** Called when send is attempted but no provider is configured. */ onNoModel?: () => void | Promise; busy?: boolean; onStop?: () => void; @@ -62,6 +76,8 @@ export function ModelComposer(p: Props) { function LiveComposer({ placeholder, inputTestId = "composer-input", onSend, onNoModel, busy, onStop, live, initialModel, allowKeyless, strictSelection, leaveGuard }: Props) { const { t, locale } = useI18n(); + const { go, route } = useNav(); + const code = route === "code" || route === "code-session"; const toast = useToast(); const enter = useSendEnter(); const instructions = enter.value === null ? t("common.sendEnterUnavailable") : enter.value ? t("system.settings.t.general.enterDesc") : t("common.sendEnterOff"); @@ -91,16 +107,12 @@ function LiveComposer({ placeholder, inputTestId = "composer-input", onSend, onN const caps = current?.capabilities; const ph = placeholder ?? t("composer.placeholder"); const hasText = !!text.trim(); - const voice = () => { if (!locked.current && !pending.current) toast.add({ title: t("system.unavailable"), description: t("chat.home.draftKept"), data: { icon: "mic" } }); }; - const setThinking = (v: boolean) => { if (locked.current || pending.current) return; localStorage.setItem(THINK_KEY, v ? "on" : "off"); setThink(v); }; - const send = async () => { if (locked.current || !latest.current.text.trim() || latest.current.busy || pending.current || reads.current) return; - const reasoning = caps?.reasoning ? think : undefined; + if (!current) { if (code) await onNoModel?.(); return; } pending.current = true; setSubmitting(true); try { - if (!current) { await onNoModel?.(); return; } - if (await onSend(latest.current.text.trim(), latest.current.files, { model: { providerID: current.providerID, modelID: current.id }, reasoning })) { + if (await onSend(latest.current.text.trim(), latest.current.files, { model: { providerID: current.providerID, modelID: current.id }, reasoning: caps?.reasoning ? think : undefined })) { latest.current.text = ""; latest.current.files = []; setText(""); setFiles([]); } } catch { @@ -118,75 +130,40 @@ function LiveComposer({ placeholder, inputTestId = "composer-input", onSend, onN } finally { reads.current--; setReading(reads.current > 0); } }; const groups = [...new Set(models.map((m) => m.providerID))]; - - return ( -
- {files.length > 0 &&
{files.map((f, i) => ( -
- {f.mime.startsWith("image/") ? : } - {f.name}{f.mime} - { if (!locked.current && !pending.current) { latest.current.files = latest.current.files.filter((_, k) => k !== i); setFiles(latest.current.files); } }} /> -
- ))}
} - { e.preventDefault(); void send(); }}> - { add(e.currentTarget.files); e.currentTarget.value = ""; }} /> - - } /> - - { if (!locked.current && !pending.current) fileRef.current?.click(); }}>{t("composer.addFiles")} - {caps?.imageInput - ? { if (!locked.current && !pending.current && fileRef.current) { fileRef.current.accept = "image/*"; fileRef.current.click(); fileRef.current.accept = ""; } }}>{t("chat.model.attachImage")} - : {t("chat.model.attachImage")}} - - -