Compare commits

...

6 Commits

Author SHA1 Message Date
Multica Eve
abfe33f350 docs: add May 13 changelog (#2529)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-13 17:40:13 +08:00
Bohan Jiang
26924dcc98 fix(desktop): restore Multica icon + WM_CLASS on Linux (MUL-2145) (#2525)
Closes the regression reported in https://github.com/multica-ai/multica/issues/2515 that
PR #2437 only half-fixed in v0.2.31.

Two gaps remained on Ubuntu/GNOME:

1. The .deb shipped only the source 1024×1024 PNG under
   /usr/share/icons/hicolor/, with no usable smaller sizes. GNOME's hicolor
   lookup walks 16…512 and falls back to the theme default when none
   match, so the launcher had no icon. The auto-generation pass in
   electron-builder silently produced only the source size for us. Drop
   pre-rendered 16/24/32/48/64/128/256/512 PNGs into build/icons/ and
   point `linux.icon` at the directory so packaging stops depending on
   the toolchain re-running that generation correctly.

2. WM_CLASS at runtime was `@multica/desktop`, while the .desktop file
   declared `StartupWMClass=Multica`. PR #2437 assumed Electron derives
   WM_CLASS from electron-builder.yml's `productName`, but Electron
   reads `app.getName()`, which reads the *packaged ASAR's* package.json
   — productName if present, otherwise name. Our source
   apps/desktop/package.json had no top-level productName, so the ASAR
   carried only `name: "@multica/desktop"` and Chromium emitted that as
   WM_CLASS, breaking the .desktop association and the dock icon.

   Fixed in two anchors for belt-and-braces: add
   `"productName": "Multica"` to apps/desktop/package.json (so the ASAR
   carries it and app.getName() resolves correctly by default), and call
   `app.setName("Multica")` in the production branch alongside the
   existing dev-only setName so a future regression in package.json or
   the build pipeline cannot silently re-break WM_CLASS.

The `StartupWMClass: Multica` declaration in electron-builder.yml stays
pinned and the surrounding comment has been rewritten to record the
correct WM_CLASS derivation.

Verification on a real Ubuntu install:
- `dpkg-deb -c multica-desktop-*-linux-amd64.deb | grep hicolor` lists
  ≥8 sizes.
- `xprop WM_CLASS` on the running window prints `"multica", "Multica"`.
- Launcher and dock both show the Multica logo with no manual
  ~/.local/share/icons workaround.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-13 17:31:52 +08:00
Bohan Jiang
e2802a5407 fix(chat): commit rename only on real outside click, not on hover (#2527)
Base UI's Menu uses focus-follows-cursor — hovering a sibling row drags
DOM focus to that row, which made the rename input's onBlur=save fire
just from moving the mouse. The result: clicking the pencil and then
nudging the cursor would silently commit a half-typed title.

Replace the blur handler with a document-level pointerdown listener
(capture phase, so it runs before Base UI's outside-click close handler
unmounts the input). The listener only commits when the user actually
clicks somewhere outside the input. Enter still commits, Escape still
cancels, mouse hover is now a no-op.

MUL-2110

Co-authored-by: multica-agent <github@multica.ai>
2026-05-13 17:23:55 +08:00
Bohan Jiang
5db96b4007 fix(daemon): bypass Gemini folder-trust gate in headless mode (#2516) (#2523)
Gemini CLI's folder-trust feature throws FatalUntrustedWorkspaceError
(exit code 55) when the current workspace isn't in
`~/.gemini/trustedFolders.json` and the process is headless — no
interactive trust prompt is available. The daemon spawns gemini with
`-p` + `--yolo` in a freshly checked-out worktree that the user has
never trusted interactively, so every run with `security.folderTrust`
enabled fails after ~10s with exit status 55 and no useful output.

Default `GEMINI_CLI_TRUST_WORKSPACE=true` on the child env to short-
circuit `checkPathTrust` in gemini-core. This mirrors gemini-cli's
documented `--skip-trust` flag; the env var has been gemini's
documented headless escape hatch for the entire folder-trust feature
lifetime so the fix works on every gemini version that can produce
the crash. Callers that explicitly set the same key in cfg.Env win,
preserving the ability to opt back into the gate.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-13 17:05:12 +08:00
Bohan Jiang
178cfb5008 fix(daemon): strip Windows chcp noise from runtime version (#2516) (#2521)
The gemini CLI's Windows shim emits `Active code page: 65001` (from
`chcp`) to stdout before the real version reaches `--version` output.
The daemon stored the raw concatenation as the runtime version, so the
runtime detail page rendered `Active code page: 65001 0.42.0` instead
of `0.42.0`.

Scan `<cli> --version` line by line and return the first line carrying
a semver-shaped token. Full strings like `2.1.5 (Claude Code)` or
`codex-cli 0.118.0` survive unchanged; unparseable output falls back to
the trimmed raw value.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-13 16:58:14 +08:00
Bohan Jiang
51aa924124 feat(chat): support renaming chat sessions inline (#2522)
Adds a pencil icon next to the trash icon on each session row in the chat
dropdown. Clicking it turns the title into an inline editable input:
Enter / blur saves, Escape cancels.

Server: new PATCH /api/chat/sessions/{id} handler that updates the title
via the existing `UpdateChatSessionTitle` sqlc query, broadcasts a new
`chat:session_updated` WS event so other tabs / devices stay in sync, and
rejects blank titles. Frontend mutation is optimistic with rollback,
matching the existing delete-session pattern.

MUL-2110

Co-authored-by: multica-agent <github@multica.ai>
2026-05-13 16:57:34 +08:00
29 changed files with 630 additions and 37 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 782 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

@@ -46,20 +46,31 @@ linux:
# Yaru). Forcing `multica` makes every Linux identity slot agree and
# matches `StartupWMClass=Multica` (productName-derived).
executableName: multica
# Pin StartupWMClass explicitly to the WM_CLASS that Electron emits on
# X11. Electron derives WM_CLASS from `app.getName()`, which in packaged
# builds resolves to `productName` (`Multica`). Without an explicit
# `StartupWMClass`, electron-builder writes `productName` as the default
# — making this declaration redundant with current settings — but
# pinning the value here turns a silent future drift (e.g. if anyone
# renames productName or sets app.setName at boot) into a visible diff
# against this file. The WM_CLASS ↔ StartupWMClass match is what lets
# GNOME associate the running window with the `.desktop` entry and
# therefore render the right icon. The post-build verification step in
# PR #2437 is `xprop WM_CLASS` on a real Ubuntu install.
# Pin StartupWMClass to the WM_CLASS Electron emits on X11. Electron
# derives WM_CLASS from `app.getName()`, which reads the *packaged*
# ASAR's `package.json` — `productName` if present, otherwise `name`.
# PR #2437 assumed electron-builder.yml's productName fed app.getName()
# directly; it does not. With our source package.json carrying only
# `name: "@multica/desktop"`, packaged Electron emitted
# `WM_CLASS=@multica/desktop`, which broke association with this entry
# and reproduced #2515 on Ubuntu 0.2.31. The fix lives in two places
# outside this file — `productName: "Multica"` on the source
# package.json (so the ASAR carries it) and `app.setName("Multica")`
# in the production branch of `src/main/index.ts` (belt-and-braces).
# Keep `StartupWMClass: Multica` pinned here so any future drift in
# those two anchors shows up as a diff against this declaration.
# Verification on a real Ubuntu install: `xprop WM_CLASS` on a running
# window prints `Multica` for both fields.
desktop:
entry:
StartupWMClass: Multica
# Point at pre-rendered hicolor sizes. electron-builder *can* generate
# 16/24/32/48/64/128/256/512 from a single build/icon.png, but the
# auto-generation silently shipped only the 1024×1024 source in our
# v0.2.31 .deb (#2515 reproduces this) — leaving GNOME's hicolor lookup
# with no usable size and falling back to the theme default. Shipping
# the sizes from source removes the toolchain dependency entirely.
icon: build/icons
target:
- AppImage
- deb

View File

@@ -1,5 +1,6 @@
{
"name": "@multica/desktop",
"productName": "Multica",
"version": "0.1.0",
"private": true,
"description": "Multica Desktop — native desktop client for the Multica platform.",

View File

@@ -212,6 +212,14 @@ const DEV_APP_NAME = process.env.DESKTOP_APP_SUFFIX
if (is.dev) {
app.setName(DEV_APP_NAME);
app.setPath("userData", join(app.getPath("appData"), DEV_APP_NAME));
} else {
// Pin the production app name in code. Electron's Linux WM_CLASS is set
// from app.getName() when the first BrowserWindow is realized; the
// packaged ASAR's package.json `productName` already steers app.getName()
// to "Multica", but anchoring it here makes WM_CLASS ↔ StartupWMClass
// (declared in electron-builder.yml) survive a regression in
// productName / the build pipeline. Must run before requestSingleInstanceLock().
app.setName("Multica");
}
// --- Protocol registration -----------------------------------------------

View File

@@ -284,6 +284,30 @@ export function createEnDict(allowSignup: boolean): LandingDict {
fixes: "Bug Fixes",
},
entries: [
{
version: "0.2.32",
date: "2026-05-13",
title: "Usage Insights, Chat Renaming & Smoother Desktop Flows",
changes: [],
features: [
"Usage now shows workspace and project token activity, runtime trends, and per-agent rankings in one place",
"Chat sessions can be renamed directly from the chat header",
"Feedback reports can include screenshots or files so teams have the context they need",
],
improvements: [
"The Usage page has clearer naming and a more dynamic agent leaderboard",
"New chats and completed chat responses update more smoothly with fewer loading flashes",
"Self-hosted GitHub setup is easier to configure and the setup docs point to the right cloud URL",
"User-installed Codex skills are available automatically when new tasks run",
],
fixes: [
"Empty successful agent responses are marked completed instead of blocked",
"Pasted mentions in instruction editors keep their mention links",
"Desktop attachment downloads use the native Linux flow and tab closing no longer loops",
"Gemini and Windows runtime startup checks are more reliable in unattended runs",
"Long GitHub repository lists stay usable when adding project resources",
],
},
{
version: "0.2.31",
date: "2026-05-12",

View File

@@ -284,6 +284,30 @@ export function createZhDict(allowSignup: boolean): LandingDict {
fixes: "问题修复",
},
entries: [
{
version: "0.2.32",
date: "2026-05-13",
title: "用量洞察、聊天重命名与桌面体验优化",
changes: [],
features: [
"Usage 页面集中展示 workspace 和 project 的 token 使用、runtime 趋势和 agent 排名",
"聊天会话可以直接在聊天页顶部重命名",
"反馈时可以附带截图或文件,方便团队快速理解问题",
],
improvements: [
"Dashboard 更名为 Usage并加入更清晰的 agent 排行展示",
"新聊天和消息完成状态切换更顺,不再频繁闪加载状态",
"自托管 GitHub 配置更完整,文档里的云端链接也已修正",
"用户安装的 Codex Skills 会自动带入新的 agent 任务",
],
fixes: [
"没有输出内容但成功完成的 agent 任务会显示为 completed不再误判为 blocked",
"在指令编辑器中粘贴的 mention 会保留可点击链接",
"Linux 桌面端下载附件时走系统原生流程,关闭标签页也不再触发循环跳转",
"Gemini 和 Windows runtime 的启动检查更稳定,适合无人值守执行",
"添加项目资源时,较长的 GitHub 仓库列表可以正常滚动",
],
},
{
version: "0.2.31",
date: "2026-05-12",

View File

@@ -1138,6 +1138,13 @@ export class ApiClient {
await this.fetch(`/api/chat/sessions/${id}`, { method: "DELETE" });
}
async updateChatSession(id: string, data: { title: string }): Promise<ChatSession> {
return this.fetch(`/api/chat/sessions/${id}`, {
method: "PATCH",
body: JSON.stringify(data),
});
}
async listChatMessages(sessionId: string): Promise<ChatMessage[]> {
return this.fetch(`/api/chat/sessions/${sessionId}/messages`);
}

View File

@@ -64,6 +64,45 @@ export function useMarkChatSessionRead() {
});
}
/**
* Renames a chat session. Optimistically swaps the title in the cached
* list so the dropdown reflects the new label immediately; rolls back on
* error. The matching `chat:session_updated` WS event keeps other
* tabs/devices in sync — see use-realtime-sync.ts.
*/
export function useUpdateChatSession() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: (data: { sessionId: string; title: string }) => {
logger.info("updateChatSession.start", {
sessionId: data.sessionId,
titleLength: data.title.length,
});
return api.updateChatSession(data.sessionId, { title: data.title });
},
onMutate: async ({ sessionId, title }) => {
await qc.cancelQueries({ queryKey: chatKeys.sessions(wsId) });
const prevSessions = qc.getQueryData<ChatSession[]>(chatKeys.sessions(wsId));
const patch = (old?: ChatSession[]) =>
old?.map((s) => (s.id === sessionId ? { ...s, title } : s));
qc.setQueryData<ChatSession[]>(chatKeys.sessions(wsId), patch);
return { prevSessions };
},
onError: (err, vars, ctx) => {
logger.error("updateChatSession.error.rollback", { sessionId: vars.sessionId, err });
if (ctx?.prevSessions) qc.setQueryData(chatKeys.sessions(wsId), ctx.prevSessions);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: chatKeys.sessions(wsId) });
},
});
}
/**
* Hard-deletes a chat session. Optimistically removes the row from the
* sessions list so the dropdown updates instantly; rolls back on error.

View File

@@ -259,6 +259,7 @@ export function useRealtimeSync(
"daemon:heartbeat",
// Chat events are handled explicitly below; do not double-invalidate.
"chat:message", "chat:done", "chat:session_read", "chat:session_deleted",
"chat:session_updated",
// task:message stays out of the prefix path because it fires per
// streamed message during a long run — invalidating the snapshot on
// every message would flood the network. Specific chat handlers below
@@ -724,6 +725,33 @@ export function useRealtimeSync(
invalidateSessionLists();
});
// chat:session_updated fires after the creator renames a session in
// any tab/device. Patch the cached row inline so the dropdown reflects
// the new title without a full sessions-list refetch.
const unsubChatSessionUpdated = ws.on("chat:session_updated", (p) => {
const payload = p as {
chat_session_id: string;
title?: string;
updated_at?: string;
};
chatWsLogger.info("chat:session_updated (global)", payload);
const id = getCurrentWsId();
if (!id) return;
const patch = (
old?: { id: string; title: string; updated_at: string }[],
) =>
old?.map((s) =>
s.id === payload.chat_session_id
? {
...s,
title: payload.title ?? s.title,
updated_at: payload.updated_at ?? s.updated_at,
}
: s,
);
qc.setQueryData(chatKeys.sessions(id), patch);
});
// chat:session_deleted fires after a hard delete. The originating tab has
// already optimistically dropped the row via useDeleteChatSession; this
// handler keeps OTHER tabs/devices in sync and also clears the active
@@ -784,6 +812,7 @@ export function useRealtimeSync(
unsubTaskFailed();
unsubChatSessionRead();
unsubChatSessionDeleted();
unsubChatSessionUpdated();
timers.forEach(clearTimeout);
timers.clear();
};

View File

@@ -54,6 +54,7 @@ export type WSEventType =
| "chat:done"
| "chat:session_read"
| "chat:session_deleted"
| "chat:session_updated"
| "project:created"
| "project:updated"
| "project:deleted"

View File

@@ -3,7 +3,7 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { motion } from "motion/react";
import { Minus, Maximize2, Minimize2, ChevronDown, ChevronRight, Plus, Check, Trash2 } from "lucide-react";
import { Minus, Maximize2, Minimize2, ChevronDown, ChevronRight, Plus, Check, Trash2, Pencil } from "lucide-react";
import { Button } from "@multica/ui/components/ui/button";
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
import {
@@ -46,6 +46,7 @@ import {
useCreateChatSession,
useDeleteChatSession,
useMarkChatSessionRead,
useUpdateChatSession,
} from "@multica/core/chat/mutations";
import { useChatStore } from "@multica/core/chat";
import { ChatMessageList, ChatMessageSkeleton } from "./chat-message-list";
@@ -734,7 +735,12 @@ function SessionDropdown({
const [showArchived, setShowArchived] = useState(false);
const [pendingDelete, setPendingDelete] = useState<ChatSession | null>(null);
// Inline rename: only one row can be in edit mode at a time. We track the
// session id (not the full session) so a stale closure can't overwrite a
// newer rename pulled in via WS.
const [renamingId, setRenamingId] = useState<string | null>(null);
const deleteSession = useDeleteChatSession();
const updateSession = useUpdateChatSession();
const setActiveSession = useChatStore((s) => s.setActiveSession);
const formatTimeAgo = useFormatTimeAgo();
@@ -773,14 +779,35 @@ function SessionDropdown({
});
};
const handleSubmitRename = (sessionId: string, raw: string) => {
const trimmed = raw.trim();
const current = sessions.find((s) => s.id === sessionId);
setRenamingId(null);
// No-op submits (unchanged or blank) skip the network round-trip — the
// server would reject a blank title anyway, and an unchanged title would
// just bump updated_at for no user-visible reason.
if (!trimmed || trimmed === current?.title) return;
updateSession.mutate({ sessionId, title: trimmed });
};
const renderRow = (session: ChatSession) => {
const isCurrent = session.id === activeSessionId;
const agent = agentById.get(session.agent_id) ?? null;
const isRunning = inFlightSessionIds.has(session.id);
const isRenaming = renamingId === session.id;
return (
<DropdownMenuItem
key={session.id}
onClick={() => onSelectSession(session)}
// While renaming we don't want a row click to select the session
// OR close the menu — the user is editing text, not navigating.
// closeOnClick=false keeps the dropdown open across input clicks
// / button clicks inside the row; the normal "click row → switch
// session → close menu" flow is unchanged when isRenaming=false.
closeOnClick={!isRenaming}
onClick={() => {
if (isRenaming) return;
onSelectSession(session);
}}
className="group flex min-w-0 items-center gap-2"
>
{agent ? (
@@ -795,45 +822,84 @@ function SessionDropdown({
<span className="size-6 shrink-0" />
)}
<div className="min-w-0 flex-1">
<div className="truncate text-sm">
{session.title?.trim() || t(($) => $.window.untitled)}
</div>
<div className="truncate text-xs text-muted-foreground/70">
{formatTimeAgo(session.updated_at)}
</div>
{isRenaming ? (
<SessionRenameInput
initialValue={session.title ?? ""}
onSubmit={(value) => handleSubmitRename(session.id, value)}
onCancel={() => setRenamingId(null)}
/>
) : (
<>
<div className="truncate text-sm">
{session.title?.trim() || t(($) => $.window.untitled)}
</div>
<div className="truncate text-xs text-muted-foreground/70">
{formatTimeAgo(session.updated_at)}
</div>
</>
)}
</div>
{/* Right-edge status pip: in-flight wins over unread because
* "still working" is more actionable than "has reply" — and
* the two rarely coexist in practice (the unread flag fires
* on chat_message write, by which point the task has just
* finished). Same pip shape as unread for visual rhythm,
* amber + pulse to read as activity. */}
{isRunning ? (
* amber + pulse to read as activity.
*
* Hidden while renaming so the inline input has room to
* breathe and trailing pips don't visually trail off-screen
* next to the editor caret. */}
{!isRenaming && isRunning ? (
<span
aria-label={t(($) => $.window.running)}
title={t(($) => $.window.running)}
className="size-1.5 shrink-0 rounded-full bg-amber-500 animate-pulse"
/>
) : session.has_unread ? (
) : !isRenaming && session.has_unread ? (
<span
aria-label={t(($) => $.window.unread)}
title={t(($) => $.window.unread)}
className="size-1.5 shrink-0 rounded-full bg-brand"
/>
) : null}
{isCurrent && <Check className="size-3.5 text-muted-foreground shrink-0" />}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
setPendingDelete(session);
}}
className="shrink-0 rounded p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-destructive/10 hover:text-destructive focus-visible:opacity-100 group-hover:opacity-100"
aria-label={t(($) => $.session_history.row_delete_aria)}
>
<Trash2 className="size-3.5" />
</button>
{!isRenaming && isCurrent && (
<Check className="size-3.5 text-muted-foreground shrink-0" />
)}
{!isRenaming && (
<>
<button
type="button"
// preventDefault is what tells Base UI's Menu.Item to skip
// its close-on-click; stopPropagation prevents the row's
// onClick from also firing (which would switch sessions).
// onPointerDown is stopped too so the menu's typeahead /
// focus tracking doesn't pre-empt the click.
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
setRenamingId(session.id);
}}
className="shrink-0 rounded p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100"
aria-label={t(($) => $.session_history.row_rename_aria)}
title={t(($) => $.session_history.row_rename_aria)}
>
<Pencil className="size-3.5" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
setPendingDelete(session);
}}
className="shrink-0 rounded p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-destructive/10 hover:text-destructive focus-visible:opacity-100 group-hover:opacity-100"
aria-label={t(($) => $.session_history.row_delete_aria)}
>
<Trash2 className="size-3.5" />
</button>
</>
)}
</DropdownMenuItem>
);
};
@@ -950,6 +1016,86 @@ function SessionDropdown({
);
}
/**
* Inline editor for a session title. Mounts focused with the existing
* title pre-selected so the user can either replace it outright or arrow
* into the existing text. Enter commits, Escape cancels, a real click
* outside the input also commits.
*
* We do NOT commit on the input's `blur` event: Base UI's Menu uses
* focus-follows-cursor (hovering a sibling row drags DOM focus there),
* so a blur handler would fire on every mouse-move and "save" the user's
* half-typed title without them clicking anywhere. Instead a document-
* level `pointerdown` listener — registered in capture phase so it runs
* before Base UI's outside-click close handler — commits when the user
* actually clicks outside the input.
*/
function SessionRenameInput({
initialValue,
onSubmit,
onCancel,
}: {
initialValue: string;
onSubmit: (value: string) => void;
onCancel: () => void;
}) {
const { t } = useT("chat");
const [value, setValue] = useState(initialValue);
const inputRef = useRef<HTMLInputElement>(null);
// Hold the latest value + callback in refs so the mount-only effect's
// listener always sees fresh state without re-subscribing on every
// keystroke (which would briefly leave a window where pointerdown isn't
// observed).
const valueRef = useRef(value);
valueRef.current = value;
const onSubmitRef = useRef(onSubmit);
onSubmitRef.current = onSubmit;
useEffect(() => {
inputRef.current?.focus();
inputRef.current?.select();
const handlePointerDown = (e: PointerEvent) => {
const input = inputRef.current;
if (!input) return;
if (input.contains(e.target as Node)) return;
onSubmitRef.current(valueRef.current);
};
// Capture phase — Base UI registers its own outside-click handler in
// bubble; running first lets us commit before the menu starts to
// close (and unmount this component).
document.addEventListener("pointerdown", handlePointerDown, true);
return () => {
document.removeEventListener("pointerdown", handlePointerDown, true);
};
}, []);
return (
<input
ref={inputRef}
type="text"
value={value}
maxLength={200}
aria-label={t(($) => $.session_history.row_rename_aria)}
onChange={(e) => setValue(e.target.value)}
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
onKeyDown={(e) => {
// Stop the menu from stealing arrow / typeahead / space input.
e.stopPropagation();
if (e.key === "Enter") {
e.preventDefault();
onSubmit(value);
} else if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
}}
className="w-full rounded-sm bg-background px-1 py-0.5 text-sm outline-none ring-1 ring-border focus-visible:ring-brand"
/>
);
}
function useFormatTimeAgo(): (dateStr: string) => string {
const { t } = useT("chat");
return (dateStr: string) => {

View File

@@ -37,6 +37,7 @@
"days": "{{count}}d ago"
},
"row_delete_aria": "Delete chat session",
"row_rename_aria": "Rename chat session",
"delete_dialog": {
"title": "Delete chat session",
"description_with_title": "\"{{title}}\" and its messages will be permanently removed. This action cannot be undone.",

View File

@@ -34,6 +34,7 @@
"days": "{{count}} 天前"
},
"row_delete_aria": "删除对话",
"row_rename_aria": "重命名对话",
"delete_dialog": {
"title": "删除对话",
"description_with_title": "\"{{title}}\" 及其消息会被永久删除,无法撤销。",

View File

@@ -519,6 +519,7 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus
r.Get("/", h.ListChatSessions)
r.Route("/{sessionId}", func(r chi.Router) {
r.Get("/", h.GetChatSession)
r.Patch("/", h.UpdateChatSession)
r.Delete("/", h.DeleteChatSession)
r.Post("/messages", h.SendChatMessage)
r.Get("/messages", h.ListChatMessages)

View File

@@ -5,6 +5,7 @@ import (
"errors"
"log/slog"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5"
@@ -14,6 +15,10 @@ import (
"github.com/multica-ai/multica/server/pkg/protocol"
)
// chatSessionTitleMaxLen caps the rename input. Long enough to fit a
// meaningful summary, short enough to keep the dropdown row scannable.
const chatSessionTitleMaxLen = 200
// ---------------------------------------------------------------------------
// Chat Sessions
// ---------------------------------------------------------------------------
@@ -232,6 +237,66 @@ func (h *Handler) GetChatSession(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, chatSessionToResponse(session))
}
type UpdateChatSessionRequest struct {
Title *string `json:"title"`
}
// UpdateChatSession updates user-editable fields on a chat session — today
// just `title`, surfaced by the inline rename affordance in the session
// dropdown. Title is the only field accepted: `status` is legacy + read-only,
// agent/creator/workspace are immutable, the resume pointers
// (session_id / work_dir / runtime_id) are daemon-owned.
func (h *Handler) UpdateChatSession(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {
return
}
workspaceID := ctxWorkspaceID(r.Context())
sessionID := chi.URLParam(r, "sessionId")
var req UpdateChatSessionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Title == nil {
writeError(w, http.StatusBadRequest, "title is required")
return
}
title := strings.TrimSpace(*req.Title)
if title == "" {
writeError(w, http.StatusBadRequest, "title is required")
return
}
if len([]rune(title)) > chatSessionTitleMaxLen {
writeError(w, http.StatusBadRequest, "title is too long")
return
}
session, ok := h.gateChatSessionForUser(w, r, userID, workspaceID, sessionID)
if !ok {
return
}
updated, err := h.Queries.UpdateChatSessionTitle(r.Context(), db.UpdateChatSessionTitleParams{
ID: session.ID,
Title: title,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to update chat session")
return
}
resolvedSessionID := uuidToString(updated.ID)
h.publishChat(protocol.EventChatSessionUpdated, workspaceID, "member", userID, resolvedSessionID, protocol.ChatSessionUpdatedPayload{
ChatSessionID: resolvedSessionID,
Title: updated.Title,
UpdatedAt: timestampToString(updated.UpdatedAt),
})
writeJSON(w, http.StatusOK, chatSessionToResponse(updated))
}
// DeleteChatSession hard-deletes a chat session owned by the caller. The
// row lock + cancel + delete run inside a single tx so a concurrent
// SendChatMessage cannot enqueue a task that would later be orphaned by

View File

@@ -107,6 +107,62 @@ func TestSendChatMessage_LinksAttachments(t *testing.T) {
}
}
// TestUpdateChatSession_RenamesTitle confirms PATCH writes the new title,
// returns the updated row, and the server-side row reflects it.
func TestUpdateChatSession_RenamesTitle(t *testing.T) {
agentID := createHandlerTestAgent(t, "ChatRenameAgent", []byte("[]"))
sessionID := createHandlerTestChatSession(t, agentID)
req := newRequest("PATCH", "/api/chat/sessions/"+sessionID, map[string]any{
"title": " Renamed Session ",
})
req = withURLParam(req, "sessionId", sessionID)
req = withChatTestWorkspaceCtx(t, req)
w := httptest.NewRecorder()
testHandler.UpdateChatSession(w, req)
if w.Code != http.StatusOK {
t.Fatalf("UpdateChatSession: expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp ChatSessionResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode update: %v", err)
}
if resp.Title != "Renamed Session" {
t.Fatalf("response title: want %q, got %q", "Renamed Session", resp.Title)
}
var dbTitle string
if err := testPool.QueryRow(
context.Background(),
`SELECT title FROM chat_session WHERE id = $1`,
sessionID,
).Scan(&dbTitle); err != nil {
t.Fatalf("query chat_session: %v", err)
}
if dbTitle != "Renamed Session" {
t.Fatalf("db title: want %q, got %q", "Renamed Session", dbTitle)
}
}
// TestUpdateChatSession_RejectsBlank refuses an empty/whitespace title with 400.
// (Untitled is a render-side fallback, not a stored value.)
func TestUpdateChatSession_RejectsBlank(t *testing.T) {
agentID := createHandlerTestAgent(t, "ChatRenameBlankAgent", []byte("[]"))
sessionID := createHandlerTestChatSession(t, agentID)
req := newRequest("PATCH", "/api/chat/sessions/"+sessionID, map[string]any{
"title": " ",
})
req = withURLParam(req, "sessionId", sessionID)
req = withChatTestWorkspaceCtx(t, req)
w := httptest.NewRecorder()
testHandler.UpdateChatSession(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("UpdateChatSession blank: expected 400, got %d: %s", w.Code, w.Body.String())
}
}
// TestSendChatMessage_InvalidAttachmentIDs rejects malformed UUIDs in
// attachment_ids with 400 before any side effects (no message row created).
func TestSendChatMessage_InvalidAttachmentIDs(t *testing.T) {

View File

@@ -581,7 +581,31 @@ func detectCLIVersion(ctx context.Context, execPath string) (string, error) {
if err != nil {
return "", fmt.Errorf("detect version for %s: %w", execPath, err)
}
return strings.TrimSpace(string(data)), nil
return extractVersionLine(string(data)), nil
}
// extractVersionLine pulls the version line out of a `<cli> --version` capture,
// discarding leading shell noise. On Windows, npm-installed CLI shims (notably
// gemini's) emit `chcp` output like `Active code page: 65001` before the real
// version reaches stdout, and the raw concatenation was being persisted as the
// runtime version (see #2516).
//
// The heuristic: return the first non-empty line that contains a semver-shaped
// token (matches versionRe). Full version strings like "2.1.5 (Claude Code)"
// or "codex-cli 0.118.0" survive unchanged because the whole matching line is
// returned. If no line carries a semver token, fall back to the trimmed raw
// output so unusual version formats aren't silently dropped to empty.
func extractVersionLine(raw string) string {
for _, line := range strings.Split(raw, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
if versionRe.MatchString(line) {
return line
}
}
return strings.TrimSpace(raw)
}
// logWriter adapts a *slog.Logger to an io.Writer for capturing stderr.

View File

@@ -41,7 +41,7 @@ func (b *geminiBackend) Execute(ctx context.Context, prompt string, opts ExecOpt
if opts.Cwd != "" {
cmd.Dir = opts.Cwd
}
cmd.Env = buildEnv(b.cfg.Env)
cmd.Env = buildGeminiEnv(b.cfg.Env)
stdout, err := cmd.StdoutPipe()
if err != nil {
@@ -265,3 +265,29 @@ func buildGeminiArgs(prompt string, opts ExecOptions, logger *slog.Logger) []str
args = append(args, filterCustomArgs(opts.CustomArgs, geminiBlockedArgs, logger)...)
return args
}
// buildGeminiEnv wraps buildEnv and defaults GEMINI_CLI_TRUST_WORKSPACE=true so
// gemini's folder-trust gate doesn't fail every headless daemon invocation with
// exit code 55 (FatalUntrustedWorkspaceError). When a user has enabled
// `security.folderTrust.enabled` in `~/.gemini/settings.json` and the daemon
// spawns gemini in a worktree that isn't pre-listed in `trustedFolders.json`,
// the CLI throws during startup warnings with no interactive prompt available,
// so the run fails after ~10s with no useful output (see #2516).
//
// The env-var bypass is gemini's own documented escape hatch (mirrors the
// `--skip-trust` CLI flag) and has been in place for the entire folder-trust
// feature lifetime, so this works on every gemini version that can produce the
// crash. If the caller explicitly sets the same key in cfg.Env it wins,
// preserving the ability to opt back into the check.
func buildGeminiEnv(extra map[string]string) []string {
const trustKey = "GEMINI_CLI_TRUST_WORKSPACE"
if _, ok := extra[trustKey]; ok {
return buildEnv(extra)
}
merged := make(map[string]string, len(extra)+1)
for k, v := range extra {
merged[k] = v
}
merged[trustKey] = "true"
return buildEnv(merged)
}

View File

@@ -2,6 +2,7 @@ package agent
import (
"log/slog"
"strings"
"testing"
)
@@ -91,6 +92,64 @@ func TestBuildGeminiArgsPassesThroughCustomArgs(t *testing.T) {
}
}
// envLookup returns the value of key in an env slice, or ("", false) if absent.
// When the key appears multiple times the last occurrence wins, mirroring how
// libc's getenv resolves duplicates on the daemon's supported platforms — the
// caller-supplied override therefore takes precedence over our default.
func envLookup(env []string, key string) (string, bool) {
prefix := key + "="
var value string
var found bool
for _, entry := range env {
if strings.HasPrefix(entry, prefix) {
value = strings.TrimPrefix(entry, prefix)
found = true
}
}
return value, found
}
func TestBuildGeminiEnvSetsTrustWorkspaceDefault(t *testing.T) {
t.Parallel()
env := buildGeminiEnv(nil)
got, ok := envLookup(env, "GEMINI_CLI_TRUST_WORKSPACE")
if !ok {
t.Fatalf("expected GEMINI_CLI_TRUST_WORKSPACE to be set, got env=%v", env)
}
if got != "true" {
t.Fatalf("expected GEMINI_CLI_TRUST_WORKSPACE=true, got %q", got)
}
}
func TestBuildGeminiEnvRespectsExplicitOverride(t *testing.T) {
t.Parallel()
// Users who deliberately set the value (e.g. to "false" to opt back into
// gemini's folder-trust gate, or to a future-proofed value) must win over
// our daemon default.
env := buildGeminiEnv(map[string]string{"GEMINI_CLI_TRUST_WORKSPACE": "false"})
got, ok := envLookup(env, "GEMINI_CLI_TRUST_WORKSPACE")
if !ok {
t.Fatalf("expected GEMINI_CLI_TRUST_WORKSPACE to be set, got env=%v", env)
}
if got != "false" {
t.Fatalf("expected caller's GEMINI_CLI_TRUST_WORKSPACE=false to win, got %q", got)
}
}
func TestBuildGeminiEnvPreservesOtherExtras(t *testing.T) {
t.Parallel()
env := buildGeminiEnv(map[string]string{"GEMINI_API_KEY": "secret"})
if got, ok := envLookup(env, "GEMINI_API_KEY"); !ok || got != "secret" {
t.Fatalf("expected GEMINI_API_KEY=secret to pass through, got %q (ok=%v)", got, ok)
}
if got, ok := envLookup(env, "GEMINI_CLI_TRUST_WORKSPACE"); !ok || got != "true" {
t.Fatalf("expected default GEMINI_CLI_TRUST_WORKSPACE=true, got %q (ok=%v)", got, ok)
}
}
func TestBuildGeminiArgsFiltersBlockedCustomArgs(t *testing.T) {
t.Parallel()

View File

@@ -78,6 +78,65 @@ func TestCheckMinCLIVersion(t *testing.T) {
}
}
func TestExtractVersionLine(t *testing.T) {
tests := []struct {
name string
raw string
want string
}{
{
name: "bare semver",
raw: "0.42.0\n",
want: "0.42.0",
},
{
name: "claude full string preserved",
raw: "2.1.5 (Claude Code)\n",
want: "2.1.5 (Claude Code)",
},
{
name: "codex prefix preserved",
raw: "codex-cli 0.118.0\n",
want: "codex-cli 0.118.0",
},
// Reproduces #2516: gemini's Windows shim emits `chcp` output to stdout
// before the real version. The chcp line has no dotted-number form,
// so the semver scan skips it and picks up "0.42.0" from the next line.
{
name: "windows chcp prefix before version",
raw: "Active code page: 65001\n0.42.0\n",
want: "0.42.0",
},
{
name: "windows chcp prefix CRLF",
raw: "Active code page: 65001\r\n0.42.0\r\n",
want: "0.42.0",
},
{
name: "leading blank lines",
raw: "\n\n 0.42.0\n",
want: "0.42.0",
},
{
name: "non-semver output falls back to trimmed raw",
raw: " some-build-id \n",
want: "some-build-id",
},
{
name: "empty input",
raw: "",
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := extractVersionLine(tt.raw); got != tt.want {
t.Errorf("extractVersionLine(%q) = %q, want %q", tt.raw, got, tt.want)
}
})
}
}
func TestCheckMinVersion(t *testing.T) {
tests := []struct {
agentType string

View File

@@ -70,6 +70,7 @@ const (
EventChatDone = "chat:done"
EventChatSessionRead = "chat:session_read"
EventChatSessionDeleted = "chat:session_deleted"
EventChatSessionUpdated = "chat:session_updated"
// Project events
EventProjectCreated = "project:created"

View File

@@ -101,6 +101,16 @@ type ChatSessionDeletedPayload struct {
ChatSessionID string `json:"chat_session_id"`
}
// ChatSessionUpdatedPayload is broadcast when a user-editable field on a
// chat session changes (today: title via inline rename). Other tabs/devices
// patch the session row in their cached list so the dropdown stays in sync
// without a full refetch.
type ChatSessionUpdatedPayload struct {
ChatSessionID string `json:"chat_session_id"`
Title string `json:"title"`
UpdatedAt string `json:"updated_at"`
}
// DaemonHeartbeatRequestPayload is sent from daemon to server over WebSocket
// to update last_seen_at and pull pending actions for a single runtime.
// Mirrors the body of POST /api/daemon/heartbeat so both transports share