{name}
diff --git a/web/app/src/pages/ConversationPage/components/ConversationPane/ConversationThreads.css b/web/app/src/pages/ConversationPage/components/ConversationPane/ConversationThreads.css
index 16e58a25..8e1a4300 100644
--- a/web/app/src/pages/ConversationPage/components/ConversationPane/ConversationThreads.css
+++ b/web/app/src/pages/ConversationPage/components/ConversationPane/ConversationThreads.css
@@ -270,14 +270,27 @@
.thread-message-avatar {
width: 30px;
height: 30px;
+ border: 0;
border-radius: var(--radius-md);
+ padding: 0;
display: grid;
place-items: center;
color: var(--avatar-text);
+ font: inherit;
font-size: 11px;
font-weight: 700;
}
+button.thread-message-avatar {
+ cursor: pointer;
+}
+
+button.thread-message-avatar:hover,
+button.thread-message-avatar:focus-visible {
+ outline: 2px solid var(--primary);
+ outline-offset: 2px;
+}
+
.thread-message-main {
min-width: 0;
}
@@ -300,6 +313,7 @@
}
.thread-composer {
+ position: relative;
padding: 14px;
border-top: 1px solid var(--line);
background: var(--surface);
@@ -307,6 +321,14 @@
gap: 8px;
}
+.thread-mention-picker {
+ left: 14px;
+ right: 14px;
+ bottom: calc(100% - 4px);
+ width: auto;
+ z-index: 5;
+}
+
.thread-composer textarea {
width: 100%;
min-height: 84px;
diff --git a/web/app/src/pages/WorkspacePage/components/ProfilePreviewPopover/ProfilePreviewPopover.tsx b/web/app/src/pages/WorkspacePage/components/ProfilePreviewPopover/ProfilePreviewPopover.tsx
index b6152141..dffd3bcd 100644
--- a/web/app/src/pages/WorkspacePage/components/ProfilePreviewPopover/ProfilePreviewPopover.tsx
+++ b/web/app/src/pages/WorkspacePage/components/ProfilePreviewPopover/ProfilePreviewPopover.tsx
@@ -4,6 +4,7 @@ import {
agentStatusLabel,
agentModelID,
formatProviderLabel,
+ formatRuntimeKindLabel,
isAgentIncomplete,
isAgentRestartNeeded,
isAgentRunning,
@@ -35,6 +36,24 @@ function profilePreviewStyle(anchorRect, cardHeight = 420) {
return { top: `${top}px`, left: `${left}px`, width: `${width}px` };
}
+function previewFieldLabel(label) {
+ return String(label || "").toLocaleUpperCase();
+}
+
+function agentModelWithReasoning(agent) {
+ const model = agentModelID(agent);
+ const reasoning = agent?.reasoning_effort || agent?.agent_profile?.reasoning_effort || "medium";
+ return reasoning ? `${model}(${reasoning})` : model;
+}
+
+function isBootstrapAdminUser(user) {
+ return (
+ user?.id === "u-admin" ||
+ String(user?.handle ?? "").toLowerCase() === "admin" ||
+ String(user?.name ?? "").toLowerCase() === "admin"
+ );
+}
+
export function ProfilePreviewPopover({
previewRef,
agent,
@@ -48,10 +67,17 @@ export function ProfilePreviewPopover({
onOpenDM,
onDelete,
}) {
+ const localAdminPreview = !agent && isBootstrapAdminUser(user);
+ const showAgentMetadataFields = Boolean(agent || localAdminPreview);
const running = agent ? isAgentRunning(agent) : false;
const incomplete = agent ? isAgentIncomplete(agent) : false;
const restartNeeded = agent ? isAgentRestartNeeded(agent) : false;
const provider = agent?.provider || agent?.agent_profile?.provider;
+ const previewRuntime = agent
+ ? formatRuntimeKindLabel(agent.runtime_kind || agent.agent_profile?.runtime_kind, t)
+ : t("profileLocalRuntime");
+ const previewProvider = agent ? formatProviderLabel(provider) : t("profileLocalProvider");
+ const previewModel = agent ? agentModelWithReasoning(agent) : localizeRole(user?.role || "admin", t);
const displayName = agent?.name || user?.name || "";
const displayRole = agent ? agent.role || "worker" : user?.role;
const deleteBusy = agent ? busyKey === `${agent.id}:delete-bot` : false;
@@ -77,7 +103,7 @@ export function ProfilePreviewPopover({
aria-label={t("profilePreview")}
>
-
{agent ? t("profilePreview") : t("personProfile")}
+
{t("profilePreview")}
}
@@ -108,65 +134,69 @@ export function ProfilePreviewPopover({
{agent?.description ?
{agent.description}
: null}
- {agent ? (
+ {showAgentMetadataFields ? (
<>
- {t("status")}
- {agentStatusLabel(agent.status, t)}
+ {previewFieldLabel(t("status"))}
+ {agent ? agentStatusLabel(agent.status, t) : t("online")}
- {t("profileProvider")}
- {formatProviderLabel(provider)}
+ {previewFieldLabel(t("profileRuntimeKind"))}
+ {previewRuntime}
- {t("profileModel")}
- {agentModelID(agent)}
+ {previewFieldLabel(t("profileProvider"))}
+ {previewProvider}
- {t("profileReasoning")}
- {agent.reasoning_effort || agent.agent_profile?.reasoning_effort || "medium"}
+ {previewFieldLabel(t("profileModel"))}
+ {previewModel}
-
- {running ? t("online") : t("offline")}
-
- {incomplete ? t("profileIncompleteBadge") : t("profileCompleteBadge")}
-
- {restartNeeded ? {t("profileRestartRequired")} : null}
-
-
-
- {canOpenDM ? (
-
- ) : null}
- {agent.role !== "manager" && agent.id !== "u-manager" ? (
-
- ) : null}
-
+ {agent ? (
+ <>
+
+ {running ? t("online") : t("offline")}
+
+ {incomplete ? t("profileIncompleteBadge") : t("profileCompleteBadge")}
+
+ {restartNeeded ? {t("profileRestartRequired")} : null}
+
+
+
+ {canOpenDM ? (
+
+ ) : null}
+ {agent.role !== "manager" && agent.id !== "u-manager" ? (
+
+ ) : null}
+
+ >
+ ) : null}
>
) : (
- {t("status")}
+ {previewFieldLabel(t("status"))}
{t("online")}
- {t("roleLabel")}
+ {previewFieldLabel(t("roleLabel"))}
{localizeRole(user?.role, t)}
- {t("handleLabel")}
+ {previewFieldLabel(t("handleLabel"))}
{user?.handle ? `@${user.handle}` : "-"}
- {t("userIDLabel")}
+ {previewFieldLabel(t("userIDLabel"))}
{user?.id || ""}
diff --git a/web/app/src/shared/i18n/messages.ts b/web/app/src/shared/i18n/messages.ts
index bd7a11b5..2536a88b 100644
--- a/web/app/src/shared/i18n/messages.ts
+++ b/web/app/src/shared/i18n/messages.ts
@@ -159,6 +159,8 @@ export const messages = {
templateLabel: "模板",
templateNone: "空白",
profilePreview: "详情预览",
+ profileLocalRuntime: "本地",
+ profileLocalProvider: "CSGClaw",
openProfile: "查看",
openDM: "私信",
personProfile: "成员资料",
@@ -477,6 +479,8 @@ export const messages = {
templateLabel: "Template",
templateNone: "Blank",
profilePreview: "Profile preview",
+ profileLocalRuntime: "Local",
+ profileLocalProvider: "CSGClaw",
openProfile: "View",
openDM: "DM",
personProfile: "Person profile",
diff --git a/web/app/tests/components/ConversationPane.test.tsx b/web/app/tests/components/ConversationPane.test.tsx
new file mode 100644
index 00000000..f32a1e6a
--- /dev/null
+++ b/web/app/tests/components/ConversationPane.test.tsx
@@ -0,0 +1,221 @@
+import { createRef, useState } from "react";
+import { render, screen, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { ConversationPane } from "@/pages/ConversationPage/components/ConversationPane/ConversationPane";
+import type { IMConversation, IMUser, ThreadView, TranslateFn } from "@/models/conversations";
+
+const users: IMUser[] = [
+ {
+ accent_hex: "#8b1d2c",
+ avatar: "AD",
+ handle: "ylong",
+ id: "u-admin",
+ name: "本地用户",
+ role: "admin",
+ },
+ {
+ accent_hex: "#0f5b66",
+ avatar: "MG",
+ handle: "manager",
+ id: "u-manager",
+ name: "manager",
+ role: "worker",
+ },
+];
+
+const roomUsers: IMUser[] = [
+ users[0],
+ {
+ accent_hex: "#4f2ec7",
+ avatar: "D",
+ handle: "dev",
+ id: "u-dev",
+ name: "dev",
+ role: "worker",
+ },
+ users[1],
+ {
+ accent_hex: "#1f57c8",
+ avatar: "Q",
+ handle: "qa",
+ id: "u-qa",
+ name: "qa",
+ role: "worker",
+ },
+ {
+ accent_hex: "#047857",
+ avatar: "U",
+ handle: "ux",
+ id: "u-ux",
+ name: "ux",
+ role: "worker",
+ },
+ {
+ accent_hex: "#0f5b66",
+ avatar: "S",
+ handle: "sales",
+ id: "u-sales",
+ name: "sales",
+ role: "worker",
+ },
+];
+
+const usersById = new Map(users.map((user) => [user.id, user]));
+
+const t: TranslateFn = (key, params = {}) => {
+ const labels: Record
= {
+ close: "Close",
+ composerTip: "Enter to send",
+ directMessagesSection: "Direct Messages",
+ inputPlaceholder: "Message",
+ latestThreadReply: "Latest reply",
+ replyInThread: "Reply in thread",
+ send: "Send",
+ threadComposerPlaceholder: "Reply in thread",
+ threadNoReplies: "No replies",
+ threadPanelTitle: "Thread",
+ };
+ if (key === "threadReplies") {
+ return `${params.count ?? 0} replies`;
+ }
+ return labels[key] ?? key;
+};
+
+function renderThreadPane(conversationMembers = users, onPreviewUser = vi.fn()) {
+ const root = {
+ content: "Hi! How can I help you today?",
+ created_at: "2026-05-25T08:13:00Z",
+ id: "msg-root",
+ sender_id: "u-manager",
+ };
+ const conversation: IMConversation = {
+ id: "room-1",
+ is_direct: true,
+ members: conversationMembers.map((user) => user.id),
+ messages: [root],
+ title: "manager",
+ };
+ const thread: ThreadView = {
+ replies: [],
+ room_id: "room-1",
+ root,
+ summary: {
+ context_summary: { root_excerpt: root.content },
+ reply_count: 0,
+ root_id: root.id,
+ },
+ };
+
+ function Harness() {
+ const [threadDraft, setThreadDraft] = useState("");
+ return (
+ ()}
+ composerError=""
+ conversation={conversation}
+ conversationMembers={conversationMembers}
+ currentUserID="u-admin"
+ draftSegments={[]}
+ draftText=""
+ editorRef={createRef()}
+ inviteActionLabel="Invite"
+ locale="zh"
+ managerProfile={null}
+ managerProfileIncomplete={false}
+ memberMenuRef={createRef()}
+ mentionCandidates={[]}
+ mentionIndex={0}
+ mentionableUsersByHandle={new Map([["manager", users[1]]])}
+ messageActionBusy={false}
+ messageActionError=""
+ messageListRef={createRef()}
+ onApplyMention={() => {}}
+ onCloseThread={() => {}}
+ onComposerCompositionEnd={() => {}}
+ onComposerCompositionStart={() => {}}
+ onComposerKeyDown={() => {}}
+ onDeleteRoom={() => {}}
+ onInviteAction={() => {}}
+ onMessageAction={() => {}}
+ onOpenThread={() => {}}
+ onProviderLogin={() => {}}
+ onPreviewUser={onPreviewUser}
+ onSendMessage={() => {}}
+ onSendThreadReply={() => {}}
+ onSyncComposer={() => {}}
+ onToggleChannelTools={() => {}}
+ onToggleMemberList={() => {}}
+ onToggleToolCalls={() => {}}
+ selectedMessageCount={1}
+ showChannelTools={false}
+ showMemberList={false}
+ showToolCalls={false}
+ t={t}
+ theme="light"
+ threadDraft={threadDraft}
+ threadError=""
+ threadLoading={false}
+ onThreadDraftChange={setThreadDraft}
+ usersById={usersById}
+ visibleMessages={[root]}
+ />
+ );
+ }
+
+ return render();
+}
+
+describe("ConversationPane", () => {
+ it("offers mention choices inside the thread composer", async () => {
+ const user = userEvent.setup();
+ renderThreadPane();
+
+ await user.type(screen.getByPlaceholderText("Reply in thread"), "@");
+
+ expect(screen.getByText("@manager")).toBeInTheDocument();
+ });
+
+ it("keeps keyboard selection while navigating thread mention choices", async () => {
+ const user = userEvent.setup();
+ renderThreadPane();
+
+ await user.type(screen.getByPlaceholderText("Reply in thread"), "@");
+ await user.keyboard("{ArrowDown}");
+
+ expect(screen.getByText("@manager").closest("button")).toHaveClass("active");
+ });
+
+ it("scrolls the active thread mention option into view while navigating", async () => {
+ const user = userEvent.setup();
+ const scrollIntoView = vi.fn();
+ const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
+ HTMLElement.prototype.scrollIntoView = scrollIntoView;
+
+ try {
+ renderThreadPane(roomUsers);
+
+ await user.type(screen.getByPlaceholderText("Reply in thread"), "@");
+ await user.keyboard("{ArrowDown}{ArrowDown}{ArrowDown}{ArrowDown}{ArrowDown}");
+
+ expect(screen.getByText("@sales").closest("button")).toHaveClass("active");
+ expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest" });
+ } finally {
+ HTMLElement.prototype.scrollIntoView = originalScrollIntoView;
+ }
+ });
+
+ it("opens profile preview from thread message avatars", async () => {
+ const user = userEvent.setup();
+ const onPreviewUser = vi.fn();
+ renderThreadPane(users, onPreviewUser);
+
+ const threadPanel = screen.getByRole("complementary", { name: "Thread" });
+ await user.click(within(threadPanel).getByRole("button", { name: "profilePreview manager" }));
+
+ expect(onPreviewUser).toHaveBeenCalledWith(users[1], expect.any(HTMLElement));
+ });
+});
diff --git a/web/app/tests/components/ProfilePreviewPopover.test.tsx b/web/app/tests/components/ProfilePreviewPopover.test.tsx
new file mode 100644
index 00000000..9af1012a
--- /dev/null
+++ b/web/app/tests/components/ProfilePreviewPopover.test.tsx
@@ -0,0 +1,114 @@
+import { createRef } from "react";
+import { render, screen, within } from "@testing-library/react";
+import { ProfilePreviewPopover } from "@/pages/WorkspacePage/components/ProfilePreviewPopover";
+
+const labels: Record = {
+ agentStatusUnknown: "Unknown",
+ close: "Close",
+ offline: "Offline",
+ online: "Online",
+ openDM: "DM",
+ openProfile: "Open",
+ profileCompleteBadge: "Complete",
+ profileLocalProvider: "CSGClaw",
+ profileLocalRuntime: "Local",
+ profileModel: "Model",
+ profilePreview: "Profile preview",
+ profileProvider: "Provider",
+ profileRestartRequired: "Restart required",
+ profileRuntimeKind: "Runtime",
+ roleLabel: "Role",
+ runtimeOpenclaw: "OpenClaw",
+ runtimePicoclaw: "PicoClaw",
+ handleLabel: "Handle",
+ personProfile: "Person profile",
+ status: "Status",
+ userIDLabel: "User ID",
+ "roles.admin": "admin",
+ "roles.worker": "Worker",
+};
+
+function t(key: string): string {
+ return labels[key] ?? key;
+}
+
+describe("ProfilePreviewPopover", () => {
+ it("shows compact agent runtime/provider/model fields with reasoning in model", () => {
+ render(
+ ()}
+ agent={{
+ id: "agent-1",
+ name: "Builder",
+ role: "worker",
+ status: "running",
+ provider: "api",
+ model_id: "gpt-5.5",
+ reasoning_effort: "medium",
+ runtime_kind: "codex",
+ }}
+ user={{ handle: "builder" }}
+ anchorRect={{ top: 20, right: 80, bottom: 60, left: 40 }}
+ t={t}
+ inDirectConversation={false}
+ busyKey=""
+ onClose={vi.fn()}
+ onOpenAgent={vi.fn()}
+ onOpenDM={vi.fn()}
+ onDelete={vi.fn()}
+ />,
+ );
+
+ const fields = screen.getByText("STATUS").closest(".preview-fields");
+ expect(fields).toBeInTheDocument();
+ expect(within(fields as HTMLElement).getByText("STATUS")).toBeInTheDocument();
+ expect(within(fields as HTMLElement).getByText("RUNTIME")).toBeInTheDocument();
+ expect(within(fields as HTMLElement).getByText("PROVIDER")).toBeInTheDocument();
+ expect(within(fields as HTMLElement).getByText("MODEL")).toBeInTheDocument();
+ expect(within(fields as HTMLElement).getByText("Codex")).toBeInTheDocument();
+ expect(within(fields as HTMLElement).getByText("OpenAI API")).toBeInTheDocument();
+ expect(within(fields as HTMLElement).getByText("gpt-5.5(medium)")).toBeInTheDocument();
+ expect(within(fields as HTMLElement).queryByText("Reasoning")).not.toBeInTheDocument();
+ });
+
+ it("uses agent-style metadata fields for local admin users", () => {
+ render(
+ ()}
+ agent={null}
+ user={{
+ accent_hex: "#dc2626",
+ avatar: "LU",
+ handle: "admin",
+ id: "u-admin",
+ name: "Local user",
+ role: "admin",
+ }}
+ anchorRect={{ top: 20, right: 80, bottom: 60, left: 40 }}
+ t={t}
+ inDirectConversation={false}
+ busyKey=""
+ onClose={vi.fn()}
+ onOpenAgent={vi.fn()}
+ onOpenDM={vi.fn()}
+ onDelete={vi.fn()}
+ />,
+ );
+
+ expect(screen.getByText("Profile preview")).toBeInTheDocument();
+ expect(screen.queryByText("Person profile")).not.toBeInTheDocument();
+
+ const fields = screen.getByText("STATUS").closest(".preview-fields");
+ expect(fields).toBeInTheDocument();
+ expect(within(fields as HTMLElement).getByText("STATUS")).toBeInTheDocument();
+ expect(within(fields as HTMLElement).getByText("RUNTIME")).toBeInTheDocument();
+ expect(within(fields as HTMLElement).getByText("PROVIDER")).toBeInTheDocument();
+ expect(within(fields as HTMLElement).getByText("MODEL")).toBeInTheDocument();
+ expect(within(fields as HTMLElement).getByText("Local")).toBeInTheDocument();
+ expect(within(fields as HTMLElement).getByText("CSGClaw")).toBeInTheDocument();
+ expect(within(fields as HTMLElement).getByText("admin")).toBeInTheDocument();
+ expect(within(fields as HTMLElement).queryByText("ROLE")).not.toBeInTheDocument();
+ expect(within(fields as HTMLElement).queryByText("HANDLE")).not.toBeInTheDocument();
+ expect(within(fields as HTMLElement).queryByText("USER ID")).not.toBeInTheDocument();
+ });
+});
diff --git a/web/app/tests/legacy-contract.test.ts b/web/app/tests/legacy-contract.test.ts
index f870f08d..eee9fa9a 100644
--- a/web/app/tests/legacy-contract.test.ts
+++ b/web/app/tests/legacy-contract.test.ts
@@ -103,7 +103,9 @@ describe("legacy UI contract", () => {
expect(source).toContain("message-thread-actions has-thread-summary");
expect(source).toContain("const threadBodyRef = useRef(null);");
expect(source).toContain("threadBody.scrollTop = threadBody.scrollHeight;");
- expect(source).toContain("[root?.id, replies.length, latestReplyID, loading]");
+ expect(source).toContain("[root, replies.length, latestReplyID, loading]");
+ expect(source).toContain("mentionableUsers={conversationMembers}");
+ expect(source).toContain("thread-mention-picker");
});
it("keeps the message timeline from exposing horizontal scroll", () => {
@@ -116,6 +118,12 @@ describe("legacy UI contract", () => {
expect(styles).toContain("width: 6px;");
});
+ it("keeps the mention picker scrollbar slim", () => {
+ expect(styles).toMatch(/\.mention-picker\s*\{[\s\S]*scrollbar-width:\s*thin;/);
+ expect(styles).toContain(".mention-picker::-webkit-scrollbar");
+ expect(styles).toContain(".mention-picker::-webkit-scrollbar-thumb");
+ });
+
it("shows agent running status dots in direct message rows", () => {
expect(source).toContain(
"const directAgent = isDirect && displayUser ? agents.find((item) => agentMatchesUser(item, displayUser)) : null;",
diff --git a/web/app/tests/models/composer.test.ts b/web/app/tests/models/composer.test.ts
index 25543e51..da35850e 100644
--- a/web/app/tests/models/composer.test.ts
+++ b/web/app/tests/models/composer.test.ts
@@ -1,5 +1,6 @@
import {
getComposerMentionState,
+ getMentionCandidates,
isComposerKeyboardEventComposing,
normalizeComposerSegments,
normalizeTextMentions,
@@ -85,6 +86,20 @@ describe("composer model helpers", () => {
]);
});
+ it("keeps all room mention candidates visible for an empty mention query", () => {
+ const users = [
+ { handle: "admin", id: "u-admin", name: "Admin" },
+ { handle: "manager", id: "u-manager", name: "manager" },
+ { handle: "dev", id: "u-dev", name: "dev" },
+ { handle: "ux", id: "u-ux", name: "ux" },
+ { handle: "qa", id: "u-qa", name: "qa" },
+ { handle: "sales", id: "u-sales", name: "sales" },
+ ];
+
+ expect(getMentionCandidates(users, "")).toEqual(users);
+ expect(getMentionCandidates(users, "s")).toEqual([users[5]]);
+ });
+
it("updates draft maps only when normalized segments change", () => {
const current = { room1: [{ type: "text", text: "Hello" }] };
diff --git a/web/app/tests/models/conversations.test.ts b/web/app/tests/models/conversations.test.ts
index 532e7906..fd339edd 100644
--- a/web/app/tests/models/conversations.test.ts
+++ b/web/app/tests/models/conversations.test.ts
@@ -6,6 +6,7 @@ import {
formatConversationPreview,
formatEventMessage,
formatMessagePreviewText,
+ formatTime,
isAgentRosterEvent,
isToolCallMessage,
latestAt,
@@ -16,6 +17,7 @@ import {
} from "@/models/conversations";
import { AgentActivityMsgTypes, CSGCLAW_AGENT_ACTIVITY_TYPE } from "@/shared/constants/messages";
import type { IMConversation, IMMessage } from "@/models/conversations";
+import { vi } from "vitest";
const t = (key: string) => key;
@@ -90,6 +92,25 @@ describe("conversation model helpers", () => {
expect(formatMessagePreviewText('Hi Alice')).toBe("Hi @Alice");
});
+ it("formats message timestamps with the browser local timezone", () => {
+ const spy = vi.spyOn(Date.prototype, "toLocaleTimeString").mockReturnValue("local time");
+ try {
+ expect(formatTime("2026-05-26T07:44:00Z", "en")).toBe("local time");
+ expect(formatTime("2026-05-26T07:44:00Z", "zh")).toBe("local time");
+
+ expect(spy).toHaveBeenNthCalledWith(1, "en-US", {
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+ expect(spy).toHaveBeenNthCalledWith(2, "zh-CN", {
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+ } finally {
+ spy.mockRestore();
+ }
+ });
+
it("resolves display names and agent/user matches defensively", () => {
const usersById = new Map([
["u-1", { id: "u-1", name: "Alice" }],