Compare commits
6 Commits
template-r
...
v0.2.32
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
abfe33f350 | ||
|
|
26924dcc98 | ||
|
|
e2802a5407 | ||
|
|
5db96b4007 | ||
|
|
178cfb5008 | ||
|
|
51aa924124 |
BIN
apps/desktop/build/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
apps/desktop/build/icons/16x16.png
Normal file
|
After Width: | Height: | Size: 782 B |
BIN
apps/desktop/build/icons/24x24.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
apps/desktop/build/icons/256x256.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
apps/desktop/build/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
apps/desktop/build/icons/48x48.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
apps/desktop/build/icons/512x512.png
Normal file
|
After Width: | Height: | Size: 158 KiB |
BIN
apps/desktop/build/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 -----------------------------------------------
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -54,6 +54,7 @@ export type WSEventType =
|
||||
| "chat:done"
|
||||
| "chat:session_read"
|
||||
| "chat:session_deleted"
|
||||
| "chat:session_updated"
|
||||
| "project:created"
|
||||
| "project:updated"
|
||||
| "project:deleted"
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"days": "{{count}} 天前"
|
||||
},
|
||||
"row_delete_aria": "删除对话",
|
||||
"row_rename_aria": "重命名对话",
|
||||
"delete_dialog": {
|
||||
"title": "删除对话",
|
||||
"description_with_title": "\"{{title}}\" 及其消息会被永久删除,无法撤销。",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||