diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 4431f226..a7ec6061 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -74,6 +74,7 @@ "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "@vitejs/plugin-react": "^6.1.2", + "oxc-transform-react": "0.156.0", "tailwindcss": "^4.3.3", "typescript": "^7.0.2", "vite": "^8.3.5" diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 9c355459..e868ac36 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -1,4 +1,4 @@ -import { lazy, Suspense, useCallback, useEffect, useRef, useState, type ReactNode } from "react"; +import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react"; import { Navigate, Route, Routes, useLocation, useNavigate, type Location } from "react-router"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { CloudErrorCode, type Bootstrap } from "@godmode/shared"; @@ -46,16 +46,23 @@ const SettingsDialog = lazy(() => import("@/pages/settings/settings-page")); const isSettingsPath = (pathname: string) => pathname === "/settings" || pathname.startsWith("/settings/"); +const listenTauri = (event: string, handler: (e: { payload: T }) => void) => import("@tauri-apps/api/event").then(({ listen }) => listen(event, handler)); + +const invokeTauri = (cmd: string, args?: Record) => import("@tauri-apps/api/core").then(({ invoke }) => invoke(cmd, args)); + export function App() { const qc = useQueryClient(); const navigate = useNavigate(); // The page on screen as the router sees it (through Godmode Cloud the address bar has a /d/ prefix). const location = useLocation(); const onScreen = useRef(""); - onScreen.current = `${location.pathname}${location.search}`; + const screenPath = `${location.pathname}${location.search}`; + useLayoutEffect(() => { + onScreen.current = screenPath; + }, [screenPath]); const settingsOpen = isSettingsPath(location.pathname); - const underSettings = useRef(null); - if (!settingsOpen) underSettings.current = location; + const [underSettings, setUnderSettings] = useState(settingsOpen ? null : location); + if (!settingsOpen && underSettings !== location) setUnderSettings(location); const [coreReady, setCoreReady] = useState(false); const [coreError, setCoreError] = useState(null); const [cloudIssue, setCloudIssue] = useState(null); @@ -72,8 +79,7 @@ export function App() { if (!isTauri) return; let stopped = false; let unlisten: (() => void) | undefined; - void import("@tauri-apps/api/event").then(async ({ listen }) => { - const off = await listen("navigate", (e) => navigate(e.payload)); + void listenTauri("navigate", (e) => navigate(e.payload)).then((off) => { if (stopped) off(); else unlisten = off; }); @@ -124,7 +130,7 @@ export function App() { const minimizeToTray = boot.data?.settings.general.minimizeToTray; useEffect(() => { if (!isTauri || minimizeToTray === undefined) return; - void import("@tauri-apps/api/core").then(({ invoke }) => invoke("set_close_to_tray", { enabled: minimizeToTray })).catch(() => {}); + void invokeTauri("set_close_to_tray", { enabled: minimizeToTray }).catch(() => {}); }, [minimizeToTray]); // Realtime connection once authenticated @@ -213,7 +219,7 @@ export function App() { return ( }> - + } /> } /> } /> @@ -252,7 +258,7 @@ export function App() { {settingsOpen && ( - + )} diff --git a/apps/desktop/src/components/activity/activity-overview.tsx b/apps/desktop/src/components/activity/activity-overview.tsx index b66dc8da..8d97b77c 100644 --- a/apps/desktop/src/components/activity/activity-overview.tsx +++ b/apps/desktop/src/components/activity/activity-overview.tsx @@ -3,12 +3,13 @@ import { useSearchParams } from "react-router"; import { AnimatePresence, motion } from "motion/react"; import { format, isToday, isYesterday } from "date-fns"; import { useTranslation } from "react-i18next"; +import type { TFunction } from "i18next"; import { Activity, RefreshCw, Search } from "lucide-react"; import type { Agent, Run, RunStatus, RunTrigger } from "@godmode/shared"; import { useAllAgents, useRuns } from "@/lib/hooks"; -import { dateLocale, t as translate } from "@/i18n"; +import { dateLocale } from "@/i18n"; import { errorMessage } from "@/lib/api"; -import { useLive, type LiveRun } from "@/stores/live"; +import { useLiveRunsMap, type LiveRun } from "@/stores/live"; import { cn } from "@/lib/utils"; import { AgentAvatar, EmptyState } from "@/components/common"; import { Orb } from "@/components/aicss/Orb"; @@ -26,16 +27,16 @@ const STATUSES: ("all" | RunStatus)[] = ["all", "running", "paused", "succeeded" const TRIGGERS: RunTrigger[] = ["chat", "routine", "check", "dream", "delegation", "task", "followup", "heartbeat", "manual", "api"]; -function dayLabel(d: Date) { - if (isToday(d)) return translate("common:words.today"); - if (isYesterday(d)) return translate("common:words.yesterday"); - return format(d, translate("activity:dayFormat"), { locale: dateLocale() }); +function dayLabel(d: Date, t: TFunction<["activity", "runs", "common"]>) { + if (isToday(d)) return t("common:words.today"); + if (isYesterday(d)) return t("common:words.yesterday"); + return format(d, t("activity:dayFormat"), { locale: dateLocale() }); } /** Every run of every agent, filtered by agent, trigger and outcome; `?run=` opens one. */ export function ActivityOverview() { const reduce = useReduceAnimations(); - const { t, i18n } = useTranslation(["activity", "runs", "common"]); + const { t } = useTranslation(["activity", "runs", "common"]); const [params, setParams] = useSearchParams(); const agentFilter = params.get("agent") ?? "all"; const statusFilter = params.get("status") ?? "all"; @@ -46,7 +47,7 @@ export function ActivityOverview() { const runsQ = useRuns(agentFilter, statusFilter); const { data: agents = [] } = useAllAgents(); const agentById = useMemo(() => new Map(agents.map((a) => [a.id, a])), [agents]); - const liveRuns = useLive((s) => s.runs); + const liveRuns = useLiveRunsMap(); const liveList = useMemo( // Working right now; a queued run shows in the list below as queued. () => Object.values(liveRuns).filter((r) => r.status === "running" && (agentFilter === "all" || r.agentId === agentFilter)), @@ -75,13 +76,13 @@ export function ActivityOverview() { const groups = useMemo(() => { const out: { label: string; runs: Run[] }[] = []; for (const r of runs) { - const label = dayLabel(new Date(r.startedAt ?? r.createdAt)); + const label = dayLabel(new Date(r.startedAt ?? r.createdAt), t); const last = out[out.length - 1]; if (last?.label === label) last.runs.push(r); else out.push({ label, runs: [r] }); } return out; - }, [runs, i18n.language]); + }, [runs, t]); const successRate = useMemo(() => { const finished = runs.filter((r) => r.status === "succeeded" || r.status === "failed"); diff --git a/apps/desktop/src/components/agents/agent-actions.tsx b/apps/desktop/src/components/agents/agent-actions.tsx index ecca6d5f..c835be39 100644 --- a/apps/desktop/src/components/agents/agent-actions.tsx +++ b/apps/desktop/src/components/agents/agent-actions.tsx @@ -12,7 +12,7 @@ import { qk } from "@/lib/queryKeys"; import { useMissingLogins, useScopeWorkspace } from "@/lib/hooks"; import { modKey } from "@/lib/desktop"; import { clearDraft, useDraft } from "@/lib/drafts"; -import { useLive, type LiveRun } from "@/stores/live"; +import { useLive, useLiveRunsMap, type LiveRun } from "@/stores/live"; import { AgentAvatar, Kbd } from "@/components/common"; import { liveMood } from "@/components/chat/conversation-mood"; import { LiveDot } from "@/components/aicss/Motion"; @@ -52,7 +52,7 @@ export function useAgentLiveRuns(agentId: string | undefined): LiveRun[] { .join(",") : "", ); - const runs = useLive((s) => s.runs); + const runs = useLiveRunsMap(); return useMemo(() => (ids ? ids.split(",").flatMap((id) => (runs[id] ? [runs[id]] : [])) : []), [ids, runs]); } diff --git a/apps/desktop/src/components/agents/agent-dnd.tsx b/apps/desktop/src/components/agents/agent-dnd.tsx index 00fe2bc2..b77ceddc 100644 --- a/apps/desktop/src/components/agents/agent-dnd.tsx +++ b/apps/desktop/src/components/agents/agent-dnd.tsx @@ -324,14 +324,14 @@ function Dock({ function DockTarget({ workspace, here }: { workspace: Workspace | null; here: boolean }) { const { t } = useTranslation("agents"); - const drop = useAgentDrop({ kind: "workspace", workspaceId: workspace?.id ?? null }, `dock:${workspace?.id ?? "global"}`); + const { ref: dropRef, state: dropState } = useAgentDrop({ kind: "workspace", workspaceId: workspace?.id ?? null }, `dock:${workspace?.id ?? "global"}`); return (
@@ -342,7 +342,8 @@ function DockTarget({ workspace, here }: { workspace: Workspace | null; here: bo } /** Makes an agent draggable; the built-in agent stays put. */ -export function useAgentDrag(agent: Agent, id = `agent:${agent.id}`) { +export function useAgentDrag(agent: Agent, dragId?: string) { + const id = dragId ?? `agent:${agent.id}`; const { setNodeRef, listeners, isDragging } = useDraggable({ id, data: { agent }, disabled: agent.isDefault }); return { ref: setNodeRef, @@ -381,9 +382,9 @@ export function DroppableSection({ target, dropId, ...props }: ComponentProps +
{children}
); diff --git a/apps/desktop/src/components/agents/agent-form.tsx b/apps/desktop/src/components/agents/agent-form.tsx index 2892a90e..2d035460 100644 --- a/apps/desktop/src/components/agents/agent-form.tsx +++ b/apps/desktop/src/components/agents/agent-form.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState, type ComponentProps, type ReactNode } from "react"; +import { useEffect, useEffectEvent, useMemo, useState, type ComponentProps, type ReactNode } from "react"; import { useQuery } from "@tanstack/react-query"; import { Link, useLocation } from "react-router"; import { Trans, useTranslation } from "react-i18next"; @@ -332,15 +332,14 @@ export function AgentForm({ }; // Save shortcut - const submitRef = useRef(submit); - submitRef.current = submit; + const onSaveShortcut = useEffectEvent(() => submit()); useEffect(() => { const onKey = (e: KeyboardEvent) => { const mod = isMac ? e.metaKey : e.ctrlKey; if (mod && e.key.toLowerCase() === "s") { e.preventDefault(); if (document.querySelector('[role="dialog"], [role="alertdialog"]')) return; - submitRef.current(); + onSaveShortcut(); } }; window.addEventListener("keydown", onKey); @@ -1315,14 +1314,17 @@ function TeamField({ const [resetNote, setResetNote] = useState(null); // A new workspace can put the chosen lead out of bounds: back to the built-in agent, and say why. - useEffect(() => { + const checkLead = useEffectEvent(() => { if (!current) return; if (leadProblem(self, current, team) === "workspace") { setResetNote(t("agentForm.team.resetNote", { lead: builtin?.name ?? "Godmode", name: current.name })); onChange(null); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [workspaceId, current?.id]); + }); + const currentId = current?.id; + useEffect(() => { + checkLead(); + }, [workspaceId, currentId]); if (isDefault) { return ( diff --git a/apps/desktop/src/components/agents/agent-list.tsx b/apps/desktop/src/components/agents/agent-list.tsx index 8bc89799..8d90fe92 100644 --- a/apps/desktop/src/components/agents/agent-list.tsx +++ b/apps/desktop/src/components/agents/agent-list.tsx @@ -60,16 +60,16 @@ function AgentRow({ const chat = useStartAgentChat(); const chatting = chat.isPending && chat.variables?.agent.id === agent.id; const lead = leadOf(agent, all); - const drag = useAgentDrag(agent); + const { ref: dragRef, props: dragProps, isDragging } = useAgentDrag(agent); return (
  • diff --git a/apps/desktop/src/components/agents/character-studio.tsx b/apps/desktop/src/components/agents/character-studio.tsx index 1ffde36f..2a1c7a59 100644 --- a/apps/desktop/src/components/agents/character-studio.tsx +++ b/apps/desktop/src/components/agents/character-studio.tsx @@ -162,7 +162,9 @@ export function PersonalityPicker({ value, onChange }: { value: string; onChange const isCustom = !!value.trim() && !personalityPreset(value); const [custom, setCustom] = useState(isCustom); const lastCustom = useRef(isCustom ? value : ""); - if (isCustom) lastCustom.current = value; + useEffect(() => { + if (isCustom) lastCustom.current = value; + }, [isCustom, value]); const customOn = custom || isCustom; const card = (selected: boolean) => diff --git a/apps/desktop/src/components/agents/detail/history-tab.tsx b/apps/desktop/src/components/agents/detail/history-tab.tsx index 2fd880ed..cacf5ab7 100644 --- a/apps/desktop/src/components/agents/detail/history-tab.tsx +++ b/apps/desktop/src/components/agents/detail/history-tab.tsx @@ -1,7 +1,7 @@ import { useMemo } from "react"; import { useQuery } from "@tanstack/react-query"; import { motion } from "motion/react"; -import { format, formatDistanceToNowStrict, isToday, isYesterday } from "date-fns"; +import { format, formatDistanceToNowStrict, isToday, isYesterday, type Locale } from "date-fns"; import { GitCommitHorizontal, History } from "lucide-react"; import { useTranslation } from "react-i18next"; import type { Agent, GitCommit } from "@godmode/shared"; @@ -11,7 +11,7 @@ import { EmptyState } from "@/components/common"; import { CopyButton } from "@/components/chat/copy-button"; import { Skeleton } from "@/components/ui/skeleton"; import { useReduceAnimations } from "@/lib/motion"; -import { dateLocale, t } from "@/i18n"; +import { t } from "@/i18n"; import { useLocale } from "@/i18n/language"; /** isomorphic-git reports seconds; accept ISO strings and ms too. */ @@ -23,10 +23,10 @@ function commitDate(ts: string): Date { return new Date(ts); } -function dayLabel(d: Date) { +function dayLabel(d: Date, locale: Locale) { if (isToday(d)) return t("common:words.today"); if (isYesterday(d)) return t("common:words.yesterday"); - return format(d, t("agents:historyTab.dayFormat"), { locale: dateLocale() }); + return format(d, t("agents:historyTab.dayFormat"), { locale }); } export function HistoryTab({ agent }: { agent: Agent }) { @@ -36,13 +36,15 @@ export function HistoryTab({ agent }: { agent: Agent }) { const q = useQuery({ queryKey: qk.agentCommits(agent.id), queryFn: () => api.agents.commits(agent.id) }); const groups = useMemo(() => { - const out: { label: string; commits: (GitCommit & { date: Date })[] }[] = []; + const out: { label: string; start: number; commits: (GitCommit & { date: Date })[] }[] = []; + let count = 0; for (const c of q.data ?? []) { const date = commitDate(c.timestamp); - const label = Number.isNaN(date.getTime()) ? t("historyTab.unknownDate") : dayLabel(date); + const label = Number.isNaN(date.getTime()) ? t("historyTab.unknownDate") : dayLabel(date, dateLocale); const last = out[out.length - 1]; if (last?.label === label) last.commits.push({ ...c, date }); - else out.push({ label, commits: [{ ...c, date }] }); + else out.push({ label, start: count, commits: [{ ...c, date }] }); + count = count + 1; } return out; }, [q.data, t, dateLocale]); @@ -67,7 +69,6 @@ export function HistoryTab({ agent }: { agent: Agent }) { ); } - let index = 0; return (

    @@ -78,10 +79,10 @@ export function HistoryTab({ agent }: { agent: Agent }) {

    {g.label}

      - {g.commits.map((c) => { + {g.commits.map((c, j) => { const [title, ...rest] = c.message.trim().split("\n"); const body = rest.join("\n").trim(); - const i = index++; + const i = g.start + j; return ( (a.workspaceId ? [a.workspaceId] : []))).size; const where = global ? t("orgChart.hub.whereGlobalAnd", { count: workspaces }) : t("orgChart.hub.whereWorkspaces", { count: workspaces }); const label = t("orgChart.hub.ariaLabel", { name: agent.name, role: agent.role || t("orgChart.noRoleLower"), status: agentPresenceLabel(presence) }); - const drop = useAgentDrop({ kind: "hub", agent }, "hub"); + const { ref: dropRef, state: dropState } = useAgentDrop({ kind: "hub", agent }, "hub"); return (
      diff --git a/apps/desktop/src/components/agents/role-templates.tsx b/apps/desktop/src/components/agents/role-templates.tsx index 07025b31..50d4192d 100644 --- a/apps/desktop/src/components/agents/role-templates.tsx +++ b/apps/desktop/src/components/agents/role-templates.tsx @@ -30,6 +30,8 @@ const searchText = (t: AgentTemplate) => { return [t.name, t.role, t.description, AGENT_TEMPLATE_CATEGORY_LABELS[t.category], ...(t.covers ?? []), shown.name, shown.role, shown.description, translate(`agents:roleTemplates.category.${t.category}`), ...(shown.covers ?? [])].join(" "); }; +const matching = (templates: AgentTemplate[], terms: RegExp[], _language: string) => templates.filter((t) => terms.every((re) => re.test(searchText(t)))); + /** * Every role a company has, by department: one agent per job, or one all-in-one agent per department that covers * them all. Searchable, because there are many. @@ -55,7 +57,7 @@ export function RoleTemplates({ const [filter, setFilter] = useState("all"); const [query, setQuery] = useState(""); const terms = useMemo(() => wordStarts(query), [query]); - const found = useMemo(() => templates.filter((t) => terms.every((re) => re.test(searchText(t)))), [templates, terms, i18n.language]); + const found = useMemo(() => matching(templates, terms, i18n.language), [templates, terms, i18n.language]); const inFilter = (t: AgentTemplate, f: Filter) => f === "all" || (f === "all-in-one" ? !!t.covers : t.category === f); const shown = found.filter((t) => inFilter(t, filter)); const grouped = AGENT_TEMPLATE_CATEGORIES.map((c) => ({ category: c, items: shown.filter((t) => t.category === c) })).filter((g) => g.items.length > 0); diff --git a/apps/desktop/src/components/agents/team-templates.tsx b/apps/desktop/src/components/agents/team-templates.tsx index 96f54893..4a755dd0 100644 --- a/apps/desktop/src/components/agents/team-templates.tsx +++ b/apps/desktop/src/components/agents/team-templates.tsx @@ -95,6 +95,8 @@ function seatsOf(node: OrgTemplateNode, byId: Map, path = const flat = (s: Seat): Seat[] => [s, ...s.reports.flatMap(flat)]; +const templateTexts = (agents: AgentTemplate[], _language: string) => new Map(agents.map((a) => [a.id, templateText(a)])); + export function StructureIcon({ icon, className }: { icon: string; className?: string }) { const Icon = ICONS[icon] ?? Users; return ( @@ -114,7 +116,7 @@ export function TeamTemplates({ kinds = ["company", "team"] }: { kinds?: TeamTem const { data: agents = [] } = useAgentTemplates(); const [kind, setKind] = useState(kinds[0]!); const [picked, setPicked] = useState(null); - const byId = useMemo(() => new Map(agents.map((a) => [a.id, templateText(a)])), [agents, i18n.language]); + const byId = useMemo(() => templateTexts(agents, i18n.language), [agents, i18n.language]); if (teams.isError) return null; const shown = (teams.data ?? []).filter((tpl) => tpl.kind === kind); return ( diff --git a/apps/desktop/src/components/aicss/ReasoningEffort.tsx b/apps/desktop/src/components/aicss/ReasoningEffort.tsx index bab46e41..5dc331a9 100644 --- a/apps/desktop/src/components/aicss/ReasoningEffort.tsx +++ b/apps/desktop/src/components/aicss/ReasoningEffort.tsx @@ -185,13 +185,13 @@ export function ReasoningEffort({ stops, value = 1, onChange, - label, - "aria-label": ariaLabel, + label: labelProp, + "aria-label": ariaLabelProp, }: ReasoningEffortProps = {}) { const { t } = useTranslation("aicss"); const STOPS = stops ?? [t("reasoningEffort.low"), t("reasoningEffort.medium"), t("reasoningEffort.high"), t("reasoningEffort.extraHigh")]; - label ??= t("reasoningEffort.label"); - ariaLabel ??= t("reasoningEffort.ariaLabel"); + const label = labelProp ?? t("reasoningEffort.label"); + const ariaLabel = ariaLabelProp ?? t("reasoningEffort.ariaLabel"); const count = STOPS.length; const rootRef = useRef(null); const trackRef = useRef(null); diff --git a/apps/desktop/src/components/android/phone-screen.tsx b/apps/desktop/src/components/android/phone-screen.tsx index 5fee12fd..7008d755 100644 --- a/apps/desktop/src/components/android/phone-screen.tsx +++ b/apps/desktop/src/components/android/phone-screen.tsx @@ -173,7 +173,8 @@ export function PhoneScreen({ error: () => { decoder = null; waitingKey = true; - if (++failures >= 3) fallBack(); + failures = failures + 1; + if (failures >= 3) fallBack(); else askKeyframe(); }, }); @@ -356,7 +357,8 @@ export function PhoneScreen({ dx += e.deltaX * unit; dy += e.deltaY * unit; at = { x: pt.x, y: pt.y }; - timer ??= setTimeout(() => { + if (timer != null) return; + timer = setTimeout(() => { timer = null; const { width, height } = size.current; if (!at || !width) return; diff --git a/apps/desktop/src/components/artifacts/artifact-frame.tsx b/apps/desktop/src/components/artifacts/artifact-frame.tsx index 21ebf7d8..ff42c447 100644 --- a/apps/desktop/src/components/artifacts/artifact-frame.tsx +++ b/apps/desktop/src/components/artifacts/artifact-frame.tsx @@ -69,13 +69,15 @@ export function ArtifactFrame({ const { resolved } = useTheme(); const ref = useRef(null); const handlersRef = useRef(handlers); - handlersRef.current = handlers; const key = useMemo(() => hashOf(html), [html]); // One token per loaded document; what it said it saved isn't sent back to it. const session = useRef({ key: "", token: "", loaded: false, lastData: "", pending: 0, queued: null }); - if (session.current.key !== key) session.current = { key, token: randomToken(), loaded: false, lastData: JSON.stringify(data ?? null), pending: 0, queued: null }; const latest = useRef({ html, data, theme: resolved }); - latest.current = { html, data, theme: resolved }; + useLayoutEffect(() => { + handlersRef.current = handlers; + if (session.current.key !== key) session.current = { key, token: randomToken(), loaded: false, lastData: JSON.stringify(data ?? null), pending: 0, queued: null }; + latest.current = { html, data, theme: resolved }; + }); // Layout effect: listening before the frame's page can say it is there. useLayoutEffect(() => { diff --git a/apps/desktop/src/components/artifacts/live-preview.tsx b/apps/desktop/src/components/artifacts/live-preview.tsx index f7b8c48c..9f82ffbe 100644 --- a/apps/desktop/src/components/artifacts/live-preview.tsx +++ b/apps/desktop/src/components/artifacts/live-preview.tsx @@ -16,16 +16,14 @@ export interface LiveInput { dataText?: string; } -function documentOf(input: LiveInput, title: string, lastSpec: { current: unknown }): string | null { +function documentOf(input: LiveInput, title: string, lastSpec: unknown): string | null { if (typeof input.html === "string") { const html = safePartialHtml(input.html); return html.trim() ? html : null; } - const spec = input.spec !== undefined && typeof input.spec === "object" ? input.spec : parsePartialJson(input.specText); - if (spec && typeof spec === "object") lastSpec.current = spec; - if (!lastSpec.current) return null; + if (!lastSpec) return null; try { - return specDocument(lastSpec.current as Parameters[0], title); + return specDocument(lastSpec as Parameters[0], title); } catch { return null; } @@ -38,21 +36,36 @@ export function hasLivePreview(input: LiveInput | undefined): boolean { export function LivePreview({ input, title, className }: { input: LiveInput; title: string; className?: string }) { const reduce = useReduceAnimations(); - const lastSpec = useRef(undefined); - const lastData = useRef(null); - const doc = documentOf(input, title, lastSpec); - const parsedData = input.data !== undefined ? input.data : parsePartialJson(input.dataText); - if (parsedData !== undefined) lastData.current = parsedData; - const data = lastData.current; + const specSource = input.spec !== undefined && typeof input.spec === "object" ? input.spec : input.specText; + const [lastSpec, setLastSpec] = useState<{ source: unknown; spec: unknown }>({ source: undefined, spec: undefined }); + let spec = lastSpec.spec; + if (typeof input.html !== "string" && specSource !== lastSpec.source) { + const parsedSpec = input.spec !== undefined && typeof input.spec === "object" ? input.spec : parsePartialJson(input.specText); + if (parsedSpec && typeof parsedSpec === "object") { + spec = parsedSpec; + setLastSpec({ source: specSource, spec: parsedSpec }); + } + } + const doc = documentOf(input, title, spec); + const dataSource = input.data !== undefined ? input.data : input.dataText; + const [lastData, setLastData] = useState<{ source: unknown; data: unknown }>({ source: undefined, data: null }); + let data = lastData.data; + if (dataSource !== lastData.source) { + const parsedData = input.data !== undefined ? input.data : parsePartialJson(input.dataText); + if (parsedData !== undefined) { + data = parsedData; + setLastData({ source: dataSource, data: parsedData }); + } + } const [slots, setSlots] = useState<[string | null, string | null]>([null, null]); const [front, setFront] = useState(0); const loadedAt = useRef(0); const timer = useRef | null>(null); const wanted = useRef(null); - wanted.current = doc; useEffect(() => { + wanted.current = doc; if (!doc) return; const load = () => { timer.current = null; diff --git a/apps/desktop/src/components/automations/followups-section.tsx b/apps/desktop/src/components/automations/followups-section.tsx index 705c7510..83fa84df 100644 --- a/apps/desktop/src/components/automations/followups-section.tsx +++ b/apps/desktop/src/components/automations/followups-section.tsx @@ -9,7 +9,7 @@ import { Spinner } from "@/components/ui/spinner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { FollowupTimePicker, followupIn, followupWhen, useFollowupActions } from "@/components/chat/followup"; import { useNow } from "@/components/vault/use-now"; -import { useLive } from "@/stores/live"; +import { useLiveRunsMap } from "@/stores/live"; import { useReduceAnimations } from "@/lib/motion"; /** Chats agents will pick up again on their own (followup_schedule), soonest first. */ @@ -18,7 +18,7 @@ export function FollowupsSection({ followups, agentById }: { followups: Followup const { t } = useTranslation(["automations", "common"]); const now = useNow(30_000); const actions = useFollowupActions(); - const liveRuns = useLive((s) => s.runs); + const liveRuns = useLiveRunsMap(); const busy = new Set(Object.values(liveRuns).flatMap((r) => (r.status === "running" ? [r.conversationId] : []))); if (!followups.length) return null; diff --git a/apps/desktop/src/components/browser/live-view.tsx b/apps/desktop/src/components/browser/live-view.tsx index 69162833..9852f91b 100644 --- a/apps/desktop/src/components/browser/live-view.tsx +++ b/apps/desktop/src/components/browser/live-view.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState, type FormEvent, type KeyboardEvent, type MouseEvent } from "react"; +import { useCallback, useEffect, useLayoutEffect, useRef, useState, type FormEvent, type KeyboardEvent, type MouseEvent } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { AnimatePresence, motion } from "motion/react"; import { useTranslation } from "react-i18next"; @@ -83,7 +83,9 @@ export function LiveView({ const controlled = expandedProp !== undefined; const expanded = expandedProp ?? expandedState; const onExpandedChangeRef = useRef(onExpandedChange); - onExpandedChangeRef.current = onExpandedChange; + useLayoutEffect(() => { + onExpandedChangeRef.current = onExpandedChange; + }); const setExpanded = useCallback( (next: boolean) => { if (!controlled) setExpandedState(next); @@ -101,7 +103,11 @@ export function LiveView({ const viewportRef = useRef(null); const imgRef = useRef(null); const frameSize = useRef({ width: 0, height: 0 }); - frameSize.current = { width: frame?.width ?? 0, height: frame?.height ?? 0 }; + const frameWidth = frame?.width ?? 0; + const frameHeight = frame?.height ?? 0; + useLayoutEffect(() => { + frameSize.current = { width: frameWidth, height: frameHeight }; + }, [frameWidth, frameHeight]); // Subscribe to frames while the profile is running. useEffect(() => { diff --git a/apps/desktop/src/components/chat/agent-picker.tsx b/apps/desktop/src/components/chat/agent-picker.tsx index 6c8ea49a..78e67c14 100644 --- a/apps/desktop/src/components/chat/agent-picker.tsx +++ b/apps/desktop/src/components/chat/agent-picker.tsx @@ -7,7 +7,7 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { AgentAvatar } from "@/components/common"; import { useWorkspaces } from "@/lib/hooks"; -import { useLive } from "@/stores/live"; +import { useLiveRunsMap } from "@/stores/live"; import { cn } from "@/lib/utils"; /** Pill button that opens a searchable agent list. */ @@ -26,7 +26,7 @@ export function AgentPicker({ const [open, setOpen] = useState(false); const navigate = useNavigate(); const { data: workspaces = [] } = useWorkspaces(); - const liveRuns = useLive((s) => s.runs); + const liveRuns = useLiveRunsMap(); const running = useMemo(() => new Set(Object.values(liveRuns).flatMap((r) => (r.status === "running" ? [r.agentId] : []))), [liveRuns]); const current = agents.find((a) => a.id === value) ?? null; diff --git a/apps/desktop/src/components/chat/composer.tsx b/apps/desktop/src/components/chat/composer.tsx index a2dc946c..4f74363d 100644 --- a/apps/desktop/src/components/chat/composer.tsx +++ b/apps/desktop/src/components/chat/composer.tsx @@ -137,9 +137,11 @@ export function Composer({ // Revoke thumbnails on unmount, unless they stay in the draft const attachmentsRef = useRef(attachments); - attachmentsRef.current = attachments; const disabledRef = useRef(disabled); - disabledRef.current = disabled; + useLayoutEffect(() => { + attachmentsRef.current = attachments; + disabledRef.current = disabled; + }); const keepsFiles = !!filesKey; useEffect(() => () => { if (!keepsFiles) attachmentsRef.current.forEach((a) => a.previewUrl && URL.revokeObjectURL(a.previewUrl)); @@ -182,7 +184,7 @@ export function Composer({ if (files.length === 0 || disabledRef.current) return; const read = await readAttachments(files, totalBytes(attachmentsRef.current), toast.warning); if (read.length) setAttachments((prev) => [...prev, ...read]); - }, []); + }, [setAttachments]); useImperativeHandle( ref, @@ -203,7 +205,7 @@ export function Composer({ }, addFiles: (files: File[]) => void addFiles(files), }), - [addFiles], + [addFiles, setText], ); const canSend = (text.trim().length > 0 || attachments.length > 0) && !busy && !blocked && !disabled; diff --git a/apps/desktop/src/components/chat/folder-picker.tsx b/apps/desktop/src/components/chat/folder-picker.tsx index 15f5ba4f..47455ba5 100644 --- a/apps/desktop/src/components/chat/folder-picker.tsx +++ b/apps/desktop/src/components/chat/folder-picker.tsx @@ -62,6 +62,8 @@ export function useShortPath(): (path: string) => string { return (path) => shortPath(path, boot?.homeDir); } +const loadDialogPlugin = () => import("@tauri-apps/plugin-dialog"); + const looksLikePath = (q: string) => /^(\/|~|[a-zA-Z]:[\\/])/.test(q.trim()); function lastFolder(): string | undefined { @@ -190,9 +192,8 @@ export function FolderPickerDialog({ setBrowsed(true); } catch (err) { toast.error(t("folderPicker.openError"), { description: errorMessage(err) }); - } finally { - setPending(null); } + setPending(null); }; const pick = (p: string) => { @@ -203,7 +204,7 @@ export function FolderPickerDialog({ const browseNative = async () => { try { - const { open: openDialog } = await import("@tauri-apps/plugin-dialog"); + const { open: openDialog } = await loadDialogPlugin(); const picked = await openDialog({ directory: true, defaultPath: current?.path }); if (typeof picked === "string") pick(picked); } catch (err) { diff --git a/apps/desktop/src/components/chat/followup.tsx b/apps/desktop/src/components/chat/followup.tsx index 32f02e7b..72711e51 100644 --- a/apps/desktop/src/components/chat/followup.tsx +++ b/apps/desktop/src/components/chat/followup.tsx @@ -115,9 +115,8 @@ export function FollowupTimePicker({ setOpen(false); } catch { /* the caller shows why */ - } finally { - setBusy(false); } + setBusy(false); }; return ( diff --git a/apps/desktop/src/components/chat/message-blocks.tsx b/apps/desktop/src/components/chat/message-blocks.tsx index aed34be8..174039a3 100644 --- a/apps/desktop/src/components/chat/message-blocks.tsx +++ b/apps/desktop/src/components/chat/message-blocks.tsx @@ -32,6 +32,7 @@ import { useSmoothText } from "./use-smooth-text"; import { UserBubble } from "./user-bubble"; import { CopyButton } from "./copy-button"; import { Lightbox } from "./lightbox"; +import { useCoreFileUrl } from "./core-file"; import { PauseMarker } from "./pause"; import { QuestionCard, viewOfBlock } from "./question-card"; import { ArtifactCard } from "@/components/artifacts/artifact-panel"; @@ -419,12 +420,12 @@ const FILE_EDIT_TOOLS = new Set(["Edit", "MultiEdit", "Write"]); type FileEditInput = { file_path?: string; notebook_path?: string; old_string?: string; new_string?: string; content?: string; edits?: { old_string?: string; new_string?: string }[] }; /** Diff rows for file-editing tools (Edit / MultiEdit / Write), or null for anything else. */ -function fileEditOf(block: ToolUseBlock): { file: string; rows: DiffRow[] } | null { - const input = (block.input ?? {}) as FileEditInput; +function fileEditOf(name: string, raw: unknown): { file: string; rows: DiffRow[] } | null { + const input = (raw ?? {}) as FileEditInput; const file = input.file_path ?? input.notebook_path ?? ""; - if (block.name === "Edit" && typeof input.new_string === "string") return { file, rows: diffLines(input.old_string ?? "", input.new_string) }; - if (block.name === "Write" && typeof input.content === "string") return { file, rows: diffLines("", input.content) }; - if (block.name === "MultiEdit" && Array.isArray(input.edits)) { + if (name === "Edit" && typeof input.new_string === "string") return { file, rows: diffLines(input.old_string ?? "", input.new_string) }; + if (name === "Write" && typeof input.content === "string") return { file, rows: diffLines("", input.content) }; + if (name === "MultiEdit" && Array.isArray(input.edits)) { const rows: DiffRow[] = []; input.edits.forEach((e, i) => { if (i > 0) rows.push({ old: null, cur: null, type: "fold", text: "⋯" }); @@ -441,6 +442,16 @@ function imageSrc(image: string): string { return `data:${mime};base64,${image}`; } +type Picture = Pick; + +const hasPicture = (b: Picture) => !!(b.image || b.imageUrl); + +function usePictureSrc(picture: Picture): string | null { + const data = useMemo(() => (picture.image ? imageSrc(picture.image) : null), [picture.image]); + const remote = useCoreFileUrl(data ? undefined : picture.imageUrl, true); + return data ?? remote.src; +} + function StreamingMarkdown({ text, className }: { text: string; className?: string }) { const reduce = useReduceAnimations(); const smooth = useSmoothText(text); @@ -608,7 +619,7 @@ function CompactToolGroup({ steps, ctx, streaming }: { steps: Step[]; ctx: ToolC const metas = tools.map((s) => describe(s.block.name, s.block.input, ctx)); const runningIdx = streaming ? tools.findIndex((s) => stepRunning(s.block, true)) : -1; const failed = tools.filter((s) => s.block.isError).length; - const images = tools.filter((s) => s.block.image).map((s) => s.block.image!); + const images = tools.filter((s) => hasPicture(s.block)).map((s) => s.block); const expanded = manual ?? (streaming && !easy); const single = tools.length === 1; @@ -651,7 +662,7 @@ function CompactToolGroup({ steps, ctx, streaming }: { steps: Step[]; ctx: ToolC {!expanded && images.length > 0 && ( {images.slice(-3).map((img, i) => ( - + ))} )} @@ -738,7 +749,7 @@ function ToolStep({ ); // Keyed by content: streaming deltas re-create `block` on every token, but the edit itself rarely changes. const editKey = FILE_EDIT_TOOLS.has(block.name) ? JSON.stringify(block.input) : ""; - const edit = useMemo(() => (editKey ? fileEditOf(block) : null), [editKey]); // eslint-disable-line react-hooks/exhaustive-deps + const edit = useMemo(() => (editKey ? fileEditOf(block.name, JSON.parse(editKey)) : null), [editKey, block.name]); const header = (