fix dashboard chat session titles

This commit is contained in:
Shannon Sands 2026-06-22 12:56:02 +10:00 committed by Teknium
parent 5dae502b86
commit b9b4756ab4
7 changed files with 237 additions and 84 deletions

View file

@ -2127,8 +2127,10 @@ def test_session_title_clears_pending_after_persist(monkeypatch):
return True
db = _FakeDB()
emitted = []
server._sessions["sid"] = _session(pending_title="stale")
monkeypatch.setattr(server, "_get_db", lambda: db)
monkeypatch.setattr(server, "_emit", lambda *args: emitted.append(args))
try:
resp = server.handle_request(
{
@ -2141,6 +2143,8 @@ def test_session_title_clears_pending_after_persist(monkeypatch):
assert resp["result"]["pending"] is False
assert resp["result"]["title"] == "fresh"
assert server._sessions["sid"]["pending_title"] is None
assert emitted[-1][0:2] == ("session.info", "sid")
assert emitted[-1][2]["title"] == "fresh"
finally:
server._sessions.pop("sid", None)
@ -4461,6 +4465,22 @@ def test_session_info_includes_mcp_servers(monkeypatch):
assert info["mcp_servers"] == fake_status
def test_session_info_includes_session_title(monkeypatch):
class _FakeDB:
def get_session_title(self, key):
assert key == "session-key"
return "Dashboard title"
monkeypatch.setattr(server, "_get_db", lambda: _FakeDB())
info = server._session_info(
types.SimpleNamespace(tools=[], model="test/model", provider="openai-codex"),
{"session_key": "session-key", "history": []},
)
assert info["title"] == "Dashboard title"
# ---------------------------------------------------------------------------
# History-mutating commands must reject while session.running is True.
# Without these guards, prompt.submit's post-run history write either

View file

@ -2696,6 +2696,9 @@ def _session_info(agent, session: dict | None = None) -> dict:
session = candidate
break
cwd = _session_cwd(session)
session_key = str(
(session or {}).get("session_key") or getattr(agent, "session_id", "") or ""
)
cfg_personality = ((_load_cfg().get("display") or {}).get("personality") or "")
personality = (session or {}).get("personality", cfg_personality)
reasoning_config = getattr(agent, "reasoning_config", None)
@ -2720,8 +2723,9 @@ def _session_info(agent, session: dict | None = None) -> dict:
is_session_yolo_enabled,
)
session_key = (session or {}).get("session_key")
session_yolo = bool(is_session_yolo_enabled(session_key)) if session_key else False
session_yolo = (
bool(is_session_yolo_enabled(session_key)) if session_key else False
)
yolo = bool(_YOLO_MODE_FROZEN) or session_yolo or _get_approval_mode() == "off"
except Exception:
yolo = False
@ -2738,6 +2742,7 @@ def _session_info(agent, session: dict | None = None) -> dict:
"branch": _git_branch_for_cwd(cwd),
"personality": str(personality or ""),
"running": bool((session or {}).get("running")),
"title": _session_live_title(session or {}, session_key) if session_key else "",
"desktop_contract": DESKTOP_BACKEND_CONTRACT,
"version": "",
"release_date": "",
@ -2802,6 +2807,16 @@ def _tool_ctx(name: str, args: dict) -> str:
return ""
def _emit_session_info_for_session(sid: str, session: dict) -> None:
agent = session.get("agent")
if agent is None:
return
try:
_emit("session.info", sid, _session_info(agent, session))
except Exception:
pass
# Tool Args/Result text shipped to the TUI for the verbose trail line. The TUI
# renders only a small persisted preview (ui-tui VERBOSE_TRAIL_MAX_CHARS), kept
# all session and expanded by default — so shipping more than that is pure pipe
@ -5097,6 +5112,7 @@ def _(rid, params: dict) -> dict:
session["pending_title"] = None
except Exception:
resolved_title = fallback
_emit_session_info_for_session(params.get("session_id", ""), session)
return _ok(
rid,
{
@ -5110,11 +5126,13 @@ def _(rid, params: dict) -> dict:
try:
if db.set_session_title(key, title):
session["pending_title"] = None
_emit_session_info_for_session(params.get("session_id", ""), session)
return _ok(rid, {"pending": False, "title": title})
# rowcount == 0 can mean "same value" as well as "missing row".
existing_row = db.get_session(key)
if existing_row:
session["pending_title"] = None
_emit_session_info_for_session(params.get("session_id", ""), session)
return _ok(
rid,
{
@ -5136,10 +5154,12 @@ def _(rid, params: dict) -> dict:
with _session_db(session) as scoped_db:
if scoped_db is not None and scoped_db.set_session_title(key, title):
session["pending_title"] = None
_emit_session_info_for_session(params.get("session_id", ""), session)
return _ok(rid, {"pending": False, "title": title})
# Row creation didn't take (DB unavailable, or a concurrent writer) —
# fall back to queuing so the post-turn apply block can still recover.
session["pending_title"] = title
_emit_session_info_for_session(params.get("session_id", ""), session)
return _ok(rid, {"pending": True, "title": title})
except ValueError as e:
return _err(rid, 4022, str(e))

View file

@ -34,6 +34,7 @@ import { ReasoningPicker } from "@/components/ReasoningPicker";
import { ToolCall, type ToolEntry } from "@/components/ToolCall";
import { GatewayClient, type ConnectionState } from "@/lib/gatewayClient";
import { api, HERMES_BASE_PATH, buildWsAuthParam } from "@/lib/api";
import { titleFromSessionInfoPayload } from "@/lib/chat-title";
import { cn } from "@/lib/utils";
import { AlertCircle, ChevronDown, RefreshCw } from "lucide-react";
@ -44,6 +45,7 @@ interface SessionInfo {
model?: string;
provider?: string;
credential_warning?: string;
title?: string;
}
interface RpcEnvelope {
@ -78,6 +80,7 @@ interface ChatSidebarProps {
profile?: string;
className?: string;
onDashboardNewSessionRequest?: () => void;
onSessionTitleChange?: (title: string | null) => void;
/**
* Render the tool-call activity card. Defaults to true. The dashboard Chat
* tab sets this false so the right rail stays a thin model + session-list
@ -91,6 +94,7 @@ export function ChatSidebar({
profile,
className,
onDashboardNewSessionRequest,
onSessionTitleChange,
showTools = true,
}: ChatSidebarProps) {
// `version` bumps on reconnect; gw is derived so we never call setState
@ -266,91 +270,96 @@ export function ChatSidebar({
});
ws.addEventListener("message", (ev) => {
let frame: RpcEnvelope;
let frame: RpcEnvelope;
try {
frame = JSON.parse(ev.data);
} catch {
return;
}
if (frame.method !== "event" || !frame.params) {
return;
}
const { type, payload } = frame.params;
if (type === "dashboard.new_session_requested") {
onDashboardNewSessionRequest?.();
} else if (type === "tool.start") {
const p = payload as
| { tool_id?: string; name?: string; context?: string }
| undefined;
const toolId = p?.tool_id;
if (!toolId) {
try {
frame = JSON.parse(ev.data);
} catch {
return;
}
setTools((prev) =>
[
...prev,
{
kind: "tool" as const,
id: `tool-${toolId}-${prev.length}`,
tool_id: toolId,
name: p?.name ?? "tool",
context: p?.context,
status: "running" as const,
startedAt: Date.now(),
},
].slice(-TOOL_LIMIT),
);
} else if (type === "tool.progress") {
const p = payload as
| { name?: string; preview?: string }
| undefined;
if (!p?.name || !p.preview) {
if (frame.method !== "event" || !frame.params) {
return;
}
setTools((prev) =>
prev.map((t) =>
t.status === "running" && t.name === p.name
? { ...t, preview: p.preview }
: t,
),
);
} else if (type === "tool.complete") {
const p = payload as
| {
tool_id?: string;
summary?: string;
error?: string;
inline_diff?: string;
}
| undefined;
const { type, payload } = frame.params;
if (!p?.tool_id) {
return;
if (type === "session.info") {
const title = titleFromSessionInfoPayload(payload);
if (title !== undefined) {
onSessionTitleChange?.(title);
}
} else if (type === "dashboard.new_session_requested") {
onDashboardNewSessionRequest?.();
} else if (type === "tool.start") {
const p = payload as
| { tool_id?: string; name?: string; context?: string }
| undefined;
const toolId = p?.tool_id;
if (!toolId) {
return;
}
setTools((prev) =>
[
...prev,
{
kind: "tool" as const,
id: `tool-${toolId}-${prev.length}`,
tool_id: toolId,
name: p?.name ?? "tool",
context: p?.context,
status: "running" as const,
startedAt: Date.now(),
},
].slice(-TOOL_LIMIT),
);
} else if (type === "tool.progress") {
const p = payload as
| { name?: string; preview?: string }
| undefined;
if (!p?.name || !p.preview) {
return;
}
setTools((prev) =>
prev.map((t) =>
t.status === "running" && t.name === p.name
? { ...t, preview: p.preview }
: t,
),
);
} else if (type === "tool.complete") {
const p = payload as
| {
tool_id?: string;
summary?: string;
error?: string;
inline_diff?: string;
}
| undefined;
if (!p?.tool_id) {
return;
}
setTools((prev) =>
prev.map((t) =>
t.tool_id === p.tool_id
? {
...t,
status: p.error ? "error" : "done",
summary: p.summary,
error: p.error,
inline_diff: p.inline_diff,
completedAt: Date.now(),
}
: t,
),
);
}
setTools((prev) =>
prev.map((t) =>
t.tool_id === p.tool_id
? {
...t,
status: p.error ? "error" : "done",
summary: p.summary,
error: p.error,
inline_diff: p.inline_diff,
completedAt: Date.now(),
}
: t,
),
);
}
});
})();
@ -358,7 +367,7 @@ export function ChatSidebar({
unmounting = true;
ws?.close();
};
}, [channel, onDashboardNewSessionRequest, version]);
}, [channel, onDashboardNewSessionRequest, onSessionTitleChange, version]);
// Seed the badge on mount and re-read it whenever the sockets are rebuilt
// (a profile/channel switch bumps `version`).

View file

@ -360,6 +360,10 @@ export const api = {
fetchJSON<SessionMessagesResponse>(
appendProfileParam(`/api/sessions/${encodeURIComponent(id)}/messages`, profile),
),
getSessionDetail: (id: string, profile = getManagementProfile()) =>
fetchJSON<SessionInfo>(
appendProfileParam(`/api/sessions/${encodeURIComponent(id)}`, profile),
),
getSessionLatestDescendant: (id: string) =>
fetchJSON<SessionLatestDescendantResponse>(
`/api/sessions/${encodeURIComponent(id)}/latest-descendant`,

View file

@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { normalizeSessionTitle, titleFromSessionInfoPayload } from "./chat-title";
describe("normalizeSessionTitle", () => {
it("trims non-empty session titles", () => {
expect(normalizeSessionTitle(" Rename the dashboard ")).toBe(
"Rename the dashboard",
);
});
it("treats blank and non-string values as no title", () => {
expect(normalizeSessionTitle(" ")).toBeNull();
expect(normalizeSessionTitle(null)).toBeNull();
expect(normalizeSessionTitle(42)).toBeNull();
});
});
describe("titleFromSessionInfoPayload", () => {
it("returns undefined when the payload has no title field", () => {
expect(titleFromSessionInfoPayload({ model: "test/model" })).toBeUndefined();
expect(titleFromSessionInfoPayload(null)).toBeUndefined();
});
it("returns null when the title field is present but empty", () => {
expect(titleFromSessionInfoPayload({ title: "" })).toBeNull();
expect(titleFromSessionInfoPayload({ title: " " })).toBeNull();
});
it("returns the normalized title when present", () => {
expect(titleFromSessionInfoPayload({ title: " Live session title " })).toBe(
"Live session title",
);
});
});

15
web/src/lib/chat-title.ts Normal file
View file

@ -0,0 +1,15 @@
export function normalizeSessionTitle(raw: unknown): string | null {
if (typeof raw !== "string") return null;
const title = raw.trim();
return title ? title : null;
}
export function titleFromSessionInfoPayload(
payload: unknown,
): string | null | undefined {
if (!payload || typeof payload !== "object" || !("title" in payload)) {
return undefined;
}
return normalizeSessionTitle((payload as { title?: unknown }).title);
}

View file

@ -36,6 +36,7 @@ import { ChatSessionList } from "@/components/ChatSessionList";
import { usePageHeader } from "@/contexts/usePageHeader";
import { useI18n } from "@/i18n";
import { api } from "@/lib/api";
import { normalizeSessionTitle } from "@/lib/chat-title";
import { PluginSlot } from "@/plugins";
import { useTheme } from "@/themes";
import { useProfileScope } from "@/contexts/useProfileScope";
@ -63,11 +64,14 @@ function buildWsUrl(
// (subscriber). Generated once per mount so a tab refresh starts a fresh
// channel — the previous PTY child terminates with the old WS, and its
// channel auto-evicts when no subscribers remain.
function generateChannelId(): string {
function generateChannelId(scope?: string): string {
const prefix = scope ? "chat" : "chat-fresh";
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
return `${prefix}-${crypto.randomUUID()}`;
}
return `chat-${Math.random().toString(36).slice(2)}-${Date.now().toString(36)}`;
return `${prefix}-${Math.random().toString(36).slice(2)}-${Date.now().toString(
36,
)}`;
}
// Colors for the terminal body. Matches the dashboard's dark teal canvas
@ -173,7 +177,11 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
// tabs because the dep wouldn't change on tab switch.
const [mobilePanelOpenRaw, setMobilePanelOpenRaw] = useState(false);
const mobilePanelOpen = isActive && mobilePanelOpenRaw;
const { setEnd } = usePageHeader();
const { setEnd, setTitle } = usePageHeader();
const [sessionTitleState, setSessionTitleState] = useState<{
scope: string;
title: string | null;
}>({ scope: "", title: null });
const { t } = useI18n();
const closeMobilePanel = useCallback(() => setMobilePanelOpenRaw(false), []);
const modelToolsLabel = useMemo(
@ -207,7 +215,47 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
// management profile. Changing it remounts the terminal (key below /
// effect dep) so the user explicitly starts a fresh scoped session.
const { profile: scopedProfile } = useProfileScope();
const channel = useMemo(() => generateChannelId(), [resumeParam, scopedProfile]);
const channel = useMemo(
() => generateChannelId(`${resumeParam ?? ""}\0${scopedProfile}`),
[resumeParam, scopedProfile],
);
const titleScope = `${channel}\0${reconnectNonce}`;
const sessionTitle =
sessionTitleState.scope === titleScope ? sessionTitleState.title : null;
const handleSessionTitleChange = useCallback(
(title: string | null) => setSessionTitleState({ scope: titleScope, title }),
[titleScope],
);
useEffect(() => {
if (!isActive) {
setTitle(null);
return;
}
setTitle(sessionTitle);
return () => setTitle(null);
}, [isActive, sessionTitle, setTitle]);
useEffect(() => {
if (!resumeParam) return;
let cancelled = false;
api
.getSessionDetail(resumeParam, scopedProfile)
.then((session) => {
if (cancelled) return;
handleSessionTitleChange(normalizeSessionTitle(session.title));
})
.catch(() => {
// Best-effort: the PTY-side session.info stream can still supply it.
});
return () => {
cancelled = true;
};
}, [resumeParam, scopedProfile, handleSessionTitleChange]);
useEffect(() => {
if (!resumeParam) return;
@ -896,6 +944,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
channel={channel}
profile={scopedProfile}
onDashboardNewSessionRequest={startFreshDashboardChat}
onSessionTitleChange={handleSessionTitleChange}
showTools={false}
/>
</div>
@ -995,6 +1044,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
channel={channel}
profile={scopedProfile}
onDashboardNewSessionRequest={startFreshDashboardChat}
onSessionTitleChange={handleSessionTitleChange}
showTools={false}
/>
</div>