fix dashboard chat session titles
This commit is contained in:
parent
5dae502b86
commit
b9b4756ab4
7 changed files with 237 additions and 84 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
35
web/src/lib/chat-title.test.ts
Normal file
35
web/src/lib/chat-title.test.ts
Normal 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
15
web/src/lib/chat-title.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue