@brainpilot/web 0.0.6 → 0.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,19 +1,15 @@
1
1
  import {
2
- Check,
3
- MessageCircle,
4
- MessageSquarePlus,
2
+ MessagesSquare,
5
3
  MonitorPlay,
6
4
  PanelLeft,
7
5
  PenLine,
8
- Search,
9
6
  Settings,
10
- Trash2,
11
- X,
12
7
  } from "lucide-react";
13
- import { FormEvent, useState } from "react";
8
+ import { useEffect, useRef, useState } from "react";
14
9
  import { useSessions } from "../../contexts/SessionContext";
15
10
  import { useT } from "../../i18n/useT";
16
11
  import { IconButton } from "../primitives/IconButton";
12
+ import { SessionList } from "./SessionList";
17
13
 
18
14
  type SidebarProps = {
19
15
  isCollapsed: boolean;
@@ -37,20 +33,46 @@ export function Sidebar({ isCollapsed, activePage, onOpenDemo, onGoWorkspace, on
37
33
  deleteSession,
38
34
  } = useSessions();
39
35
  const t = useT();
40
- const [editingId, setEditingId] = useState<string | null>(null);
41
- const [editingTitle, setEditingTitle] = useState("");
42
- const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
36
+ // #131 — when collapsed to the icon rail, the session list moves into a
37
+ // floating popover opened from a single icon, so it no longer competes for
38
+ // horizontal space yet stays one click away.
39
+ const [isSessionsPopoverOpen, setIsSessionsPopoverOpen] = useState(false);
40
+ const sessionsPopoverRef = useRef<HTMLDivElement | null>(null);
43
41
 
44
- const submitRename = async (event: FormEvent) => {
45
- event.preventDefault();
46
- if (!editingId || !editingTitle.trim()) {
47
- setEditingId(null);
48
- return;
49
- }
50
- await updateSessionTitle(editingId, editingTitle.trim());
51
- setEditingId(null);
42
+ const newConversation = () => {
43
+ onGoWorkspace();
44
+ startDraftSession();
52
45
  };
53
46
 
47
+ const selectAndGo = (sessionId: string) => {
48
+ onGoWorkspace();
49
+ selectSession(sessionId);
50
+ };
51
+
52
+ // Collapsing the rail (manually or at narrow widths) closes a stale popover.
53
+ useEffect(() => {
54
+ if (!isCollapsed) setIsSessionsPopoverOpen(false);
55
+ }, [isCollapsed]);
56
+
57
+ // Dismiss the popover on outside click / Escape, like a standard menu.
58
+ useEffect(() => {
59
+ if (!isSessionsPopoverOpen) return;
60
+ const onPointerDown = (event: PointerEvent) => {
61
+ if (!sessionsPopoverRef.current?.contains(event.target as Node)) {
62
+ setIsSessionsPopoverOpen(false);
63
+ }
64
+ };
65
+ const onKeyDown = (event: KeyboardEvent) => {
66
+ if (event.key === "Escape") setIsSessionsPopoverOpen(false);
67
+ };
68
+ window.addEventListener("pointerdown", onPointerDown);
69
+ window.addEventListener("keydown", onKeyDown);
70
+ return () => {
71
+ window.removeEventListener("pointerdown", onPointerDown);
72
+ window.removeEventListener("keydown", onKeyDown);
73
+ };
74
+ }, [isSessionsPopoverOpen]);
75
+
54
76
  return (
55
77
  <aside className="sidebar" aria-label={t("sidebar.aria.nav")}>
56
78
  <div
@@ -74,27 +96,54 @@ export function Sidebar({ isCollapsed, activePage, onOpenDemo, onGoWorkspace, on
74
96
  </div>
75
97
 
76
98
  <nav className="sidebar__nav" aria-label={t("sidebar.aria.primary")}>
77
- <button className="nav-item nav-item--strong" onClick={() => { onGoWorkspace(); startDraftSession(); }} type="button">
99
+ <button className="nav-item nav-item--strong" onClick={newConversation} type="button" title={t("sidebar.newChat")}>
78
100
  <PenLine size={16} />
79
101
  <span>{t("sidebar.newChat")}</span>
80
102
  </button>
81
103
  {/*
82
- issue #44: 插件 / 自动化 have no view yet as clickable no-op buttons
83
- they read as broken navigation. Hidden until the views exist; the
84
- i18n keys (sidebar.plugins / sidebar.automations) are kept. Re-add the
85
- Plug / Clock3 lucide imports when restoring these.
86
- <button className="nav-item" type="button">
87
- <Plug size={16} />
88
- <span>{t("sidebar.plugins")}</span>
89
- </button>
90
- <button className="nav-item" type="button">
91
- <Clock3 size={16} />
92
- <span>{t("sidebar.automations")}</span>
93
- </button>
104
+ #131 collapsed icon rail: a single Sessions icon opens the session
105
+ list in a popover (the inline list below is hidden when collapsed).
106
+ Rendered only in the rail so the expanded sidebar keeps its full list.
94
107
  */}
108
+ {isCollapsed ? (
109
+ <div className="sidebar__sessions-popover-anchor" ref={sessionsPopoverRef}>
110
+ <button
111
+ aria-expanded={isSessionsPopoverOpen}
112
+ aria-haspopup="menu"
113
+ className={`nav-item ${isSessionsPopoverOpen ? "is-active" : ""}`}
114
+ onClick={() => setIsSessionsPopoverOpen((open) => !open)}
115
+ title={t("sidebar.conversations")}
116
+ type="button"
117
+ >
118
+ <MessagesSquare size={16} />
119
+ <span>{t("sidebar.conversations")}</span>
120
+ </button>
121
+ {isSessionsPopoverOpen ? (
122
+ <div className="sidebar__sessions-popover" role="menu" aria-label={t("sidebar.conversations")}>
123
+ <div className="sidebar__sessions-popover-head">
124
+ <h2>{t("sidebar.conversations")}</h2>
125
+ <button className="nav-item nav-item--strong" onClick={() => { newConversation(); setIsSessionsPopoverOpen(false); }} type="button">
126
+ <PenLine size={14} />
127
+ <span>{t("sidebar.newChat")}</span>
128
+ </button>
129
+ </div>
130
+ <SessionList
131
+ sessions={sessions}
132
+ currentId={currentSession?.id}
133
+ isLoading={isLoading}
134
+ onSelect={(id) => { selectAndGo(id); setIsSessionsPopoverOpen(false); }}
135
+ onRename={updateSessionTitle}
136
+ onDelete={deleteSession}
137
+ onOpenSearch={() => { onOpenSearch(); setIsSessionsPopoverOpen(false); }}
138
+ />
139
+ </div>
140
+ ) : null}
141
+ </div>
142
+ ) : null}
95
143
  <button
96
144
  className={`nav-item ${activePage === "demo" ? "is-active" : ""}`}
97
145
  onClick={onOpenDemo}
146
+ title={t("sidebar.demo")}
98
147
  type="button"
99
148
  >
100
149
  <MonitorPlay size={16} />
@@ -106,82 +155,25 @@ export function Sidebar({ isCollapsed, activePage, onOpenDemo, onGoWorkspace, on
106
155
  <div className="section-heading">
107
156
  <h2 id="conversations-heading">{t("sidebar.conversations")}</h2>
108
157
  <div className="section-heading__actions">
109
- <IconButton label={t("sidebar.aria.newConversation")} onClick={() => { onGoWorkspace(); startDraftSession(); }}>
110
- <MessageSquarePlus size={13} />
158
+ <IconButton label={t("sidebar.aria.newConversation")} onClick={newConversation}>
159
+ <PenLine size={13} />
111
160
  </IconButton>
112
161
  </div>
113
162
  </div>
114
163
 
115
- <div className="conversation-stack">
116
- <button className="conversation-search-trigger" onClick={onOpenSearch} type="button">
117
- <Search size={14} />
118
- <span>{t("sidebar.search")}</span>
119
- </button>
120
- <p className="muted-label">{isLoading ? t("sidebar.loading") : t("sidebar.sessionCount", { count: sessions.length })}</p>
121
- {sessions.length === 0 && !isLoading ? <p className="sidebar-empty">{t("sidebar.empty")}</p> : null}
122
- {sessions.map((session) => {
123
- const isEditing = editingId === session.id;
124
- const isConfirming = confirmDeleteId === session.id;
125
- return (
126
- <div className={`conversation-item ${currentSession?.id === session.id ? "is-active" : ""}`} key={session.id}>
127
- {isEditing ? (
128
- <form className="conversation-edit" onSubmit={submitRename}>
129
- <input
130
- autoFocus
131
- onChange={(event) => setEditingTitle(event.target.value)}
132
- value={editingTitle}
133
- />
134
- <IconButton label={t("sidebar.aria.saveTitle")} type="submit">
135
- <Check size={14} />
136
- </IconButton>
137
- <IconButton label={t("sidebar.aria.cancelRename")} onClick={() => setEditingId(null)}>
138
- <X size={14} />
139
- </IconButton>
140
- </form>
141
- ) : (
142
- <>
143
- <button className="conversation-row" onClick={() => { onGoWorkspace(); selectSession(session.id); }} type="button">
144
- <MessageCircle size={16} />
145
- <span>{session.title}</span>
146
- <small>{new Date(session.updatedAt).toLocaleDateString()}</small>
147
- </button>
148
- <div className="conversation-actions">
149
- {isConfirming ? (
150
- <>
151
- <IconButton label={t("sidebar.aria.confirmDelete")} onClick={() => void deleteSession(session.id)}>
152
- <Check size={14} />
153
- </IconButton>
154
- <IconButton label={t("sidebar.aria.cancelDelete")} onClick={() => setConfirmDeleteId(null)}>
155
- <X size={14} />
156
- </IconButton>
157
- </>
158
- ) : (
159
- <>
160
- <IconButton
161
- label={t("sidebar.aria.rename")}
162
- onClick={() => {
163
- setEditingId(session.id);
164
- setEditingTitle(session.title);
165
- }}
166
- >
167
- <PenLine size={14} />
168
- </IconButton>
169
- <IconButton label={t("sidebar.aria.delete")} onClick={() => setConfirmDeleteId(session.id)}>
170
- <Trash2 size={14} />
171
- </IconButton>
172
- </>
173
- )}
174
- </div>
175
- </>
176
- )}
177
- </div>
178
- );
179
- })}
180
- </div>
164
+ <SessionList
165
+ sessions={sessions}
166
+ currentId={currentSession?.id}
167
+ isLoading={isLoading}
168
+ onSelect={selectAndGo}
169
+ onRename={updateSessionTitle}
170
+ onDelete={deleteSession}
171
+ onOpenSearch={onOpenSearch}
172
+ />
181
173
  </section>
182
174
 
183
175
  <div className="sidebar__footer">
184
- <button className="nav-item" onClick={onOpenSettings} type="button">
176
+ <button className="nav-item" onClick={onOpenSettings} type="button" title={t("sidebar.settings")}>
185
177
  <Settings size={16} />
186
178
  <span>{t("sidebar.settings")}</span>
187
179
  </button>
@@ -55,6 +55,14 @@ interface SessionContextValue {
55
55
  agentFilters: Record<string, AgentMessageFilter>;
56
56
  /** Live Graph of Trace for the current session (#79), or null if none/unloaded. */
57
57
  currentTrace: TraceGraph | null;
58
+ /**
59
+ * #134 — whether the current session has trace updates the user hasn't seen
60
+ * since last opening the Trace view. Drives a quiet dot on the Trace tab so
61
+ * trace stays a transparency layer instead of noisy chat output. Per-session:
62
+ * switching sessions reflects that session's unread state. False on initial
63
+ * hydration (only live post-open trace_node events set it).
64
+ */
65
+ traceUnread: boolean;
58
66
  /** Re-seed the trace graph from the HTTP route (manual refresh). */
59
67
  refreshTrace: (sessionId: string) => Promise<void>;
60
68
  selectSession: (sessionId: string) => void;
@@ -185,6 +193,14 @@ export function SessionProvider({ children }: { children: ReactNode }) {
185
193
  const [isRefreshingMessages, setIsRefreshingMessages] = useState(false);
186
194
  const [error, setError] = useState<string | null>(null);
187
195
  const [currentView, setCurrentView] = useState<"chat" | "agents" | "trace">("chat");
196
+ // #134 — read currentView inside the SSE queue-drain effect (keyed on
197
+ // session/tick, not view) to decide whether an incoming trace update should
198
+ // raise the unread dot. A live trace_node that arrives while the user is
199
+ // already on the Trace view is "seen", so it must not flag unread.
200
+ const currentViewRef = useRef<"chat" | "agents" | "trace">("chat");
201
+ useEffect(() => {
202
+ currentViewRef.current = currentView;
203
+ }, [currentView]);
188
204
  // Unsent textarea drafts live in a module-level store (see contexts/draftStore.ts)
189
205
  // so keystrokes don't re-render the whole chat subtree. Drafts are keyed by
190
206
  // session id and survive PromptComposer unmount (tab switches).
@@ -201,6 +217,11 @@ export function SessionProvider({ children }: { children: ReactNode }) {
201
217
  // #79: live Graph of Trace per session. Seeded by a fetch on session change,
202
218
  // then kept live by CUSTOM:trace_node SSE events (see the queue drain below).
203
219
  const [traceBySession, setTraceBySession] = useState<Record<string, TraceGraph>>({});
220
+ // #134 — per-session "trace changed since you last looked" flag. Set only by
221
+ // live CUSTOM:trace_node events while the user is NOT on the Trace view;
222
+ // cleared when they open Trace for that session. Hydration/seed paths never
223
+ // set it, so a freshly-opened session with existing trace shows no false dot.
224
+ const [traceUnreadBySession, setTraceUnreadBySession] = useState<Record<string, boolean>>({});
204
225
 
205
226
 
206
227
  const currentSession = useMemo(
@@ -802,7 +823,17 @@ export function SessionProvider({ children }: { children: ReactNode }) {
802
823
  for (const event of queue) {
803
824
  graph = reduceTraceForEvent(graph, event, sid);
804
825
  }
805
- return graph && graph !== start ? { ...current, [sid]: graph } : current;
826
+ if (graph && graph !== start) {
827
+ // #134 — a live trace update landed. Raise the per-session unread dot
828
+ // unless the user is already looking at this session's Trace view (then
829
+ // it's seen). Hydration/seed go through other code paths, so this only
830
+ // ever fires for genuine post-open SSE trace_node events.
831
+ if (currentViewRef.current !== "trace") {
832
+ setTraceUnreadBySession((u) => (u[sid] ? u : { ...u, [sid]: true }));
833
+ }
834
+ return { ...current, [sid]: graph };
835
+ }
836
+ return current;
806
837
  });
807
838
 
808
839
  // Process all events through the message reducer.
@@ -842,6 +873,16 @@ export function SessionProvider({ children }: { children: ReactNode }) {
842
873
  [currentSessionId, traceBySession],
843
874
  );
844
875
 
876
+ // #134 — opening the Trace view for a session marks its trace as seen, so the
877
+ // tab dot clears. Keyed on (session, view) so it also clears when a trace
878
+ // update arrives while already on the view (the drain guards that case too).
879
+ useEffect(() => {
880
+ if (currentView !== "trace" || !currentSessionId) return;
881
+ setTraceUnreadBySession((u) => (u[currentSessionId] ? { ...u, [currentSessionId]: false } : u));
882
+ }, [currentView, currentSessionId]);
883
+
884
+ const traceUnread = currentSessionId ? (traceUnreadBySession[currentSessionId] ?? false) : false;
885
+
845
886
  const value = useMemo(
846
887
  () => ({
847
888
  sessions,
@@ -859,6 +900,7 @@ export function SessionProvider({ children }: { children: ReactNode }) {
859
900
  tokenUsage,
860
901
  agentFilters,
861
902
  currentTrace,
903
+ traceUnread,
862
904
  refreshTrace,
863
905
  selectSession,
864
906
  createSession,
@@ -890,6 +932,7 @@ export function SessionProvider({ children }: { children: ReactNode }) {
890
932
  tokenUsage,
891
933
  agentFilters,
892
934
  currentTrace,
935
+ traceUnread,
893
936
  refreshTrace,
894
937
  selectSession,
895
938
  createSession,
@@ -9,6 +9,60 @@ export type RenderItem =
9
9
  | { type: "single"; message: ChatMessage }
10
10
  | { type: "activity"; id: string; steps: ChatMessage[]; streaming: boolean };
11
11
 
12
+ /**
13
+ * #134 — tool visibility model. Internal tools are part of the agent's plumbing
14
+ * (trace bookkeeping) rather than the user-facing conversation: the model still
15
+ * sees their calls and results, but the chat UI hides both so an implementation
16
+ * detail (`trace event dispatched`) never surfaces above the Principal's reply.
17
+ * Trace changes are surfaced quietly via the Trace tab badge instead.
18
+ *
19
+ * Hard-coded for the current internal toolset; promote to a richer
20
+ * `ToolVisibility = "user" | "debug" | "internal"` map if/when more land.
21
+ */
22
+ const INTERNAL_TOOL_NAMES: ReadonlySet<string> = new Set([
23
+ "record_trace",
24
+ "create_trace_node",
25
+ "update_trace_node",
26
+ "add_trace_relation",
27
+ "get_trace_graph",
28
+ ]);
29
+
30
+ /** A tool name is internal if it matches bare or mcp-namespaced (server__tool). */
31
+ export function isInternalToolName(name: string | undefined): boolean {
32
+ if (!name) return false;
33
+ if (INTERNAL_TOOL_NAMES.has(name)) return true;
34
+ const bare = name.includes("__") ? name.slice(name.lastIndexOf("__") + 2) : name;
35
+ return INTERNAL_TOOL_NAMES.has(bare);
36
+ }
37
+
38
+ /**
39
+ * Drop internal-tool calls AND their matching results from the chat stream.
40
+ *
41
+ * A TOOL_CALL_START carries the tool name; the later TOOL_CALL_RESULT carries
42
+ * only a `toolCallId` linking back to it. So we first collect the call ids of
43
+ * every internal tool, then filter out both the call message and any tool
44
+ * message whose `toolCallId` (the result) points at one. Presentation-only:
45
+ * the underlying message list and the model's view are untouched.
46
+ */
47
+ export function stripInternalToolMessages(messages: ChatMessage[]): ChatMessage[] {
48
+ const internalCallIds = new Set<string>();
49
+ for (const m of messages) {
50
+ if (m.kind === "tool" && isInternalToolName(m.toolName)) {
51
+ internalCallIds.add(m.id);
52
+ if (m.toolCallId) internalCallIds.add(m.toolCallId);
53
+ }
54
+ }
55
+ if (internalCallIds.size === 0) return messages;
56
+ return messages.filter((m) => {
57
+ if (m.kind !== "tool") return true;
58
+ // The call itself (internal tool name) → drop.
59
+ if (isInternalToolName(m.toolName)) return false;
60
+ // The result, linked by toolCallId to an internal call → drop.
61
+ if (m.toolCallId && internalCallIds.has(m.toolCallId)) return false;
62
+ return true;
63
+ });
64
+ }
65
+
12
66
  /**
13
67
  * Standalone kinds render as their own visible card. All assistant text
14
68
  * messages — whether they come from the Principal or an Expert agent, and
@@ -49,6 +103,8 @@ export function buildRenderItems(
49
103
  runningAgents?: ReadonlySet<string>,
50
104
  ): RenderItem[] {
51
105
  const items: RenderItem[] = [];
106
+ // #134 — internal tools (trace bookkeeping) are hidden from the chat UI.
107
+ messages = stripInternalToolMessages(messages);
52
108
  let buffer: ChatMessage[] = [];
53
109
  // A step keeps its block "in progress" while its owning agent's run is active.
54
110
  // Steps default to the principal agent when unattributed, matching how the
@@ -258,6 +258,10 @@ export function reduceMessagesForEvent(existing: ChatMessage[], event: WebSocket
258
258
  agent,
259
259
  kind: "tool",
260
260
  toolResult: content,
261
+ // #134 — keep the link back to the originating TOOL_CALL_START so the
262
+ // UI can suppress results of internal tools (record_trace) whose name
263
+ // only rode on the call event, not on this result.
264
+ toolCallId: event.toolCallId,
261
265
  },
262
266
  ];
263
267
  }
@@ -11,6 +11,7 @@ export default defineMessages(
11
11
  "shell.view.chat": "对话",
12
12
  "shell.view.agents": "智能体",
13
13
  "shell.view.trace": "轨迹",
14
+ "shell.view.traceUpdated": "轨迹有新更新",
14
15
  "shell.aria.refreshMessages": "刷新消息",
15
16
  "shell.terminal.minimize": "最小化终端",
16
17
  "shell.terminal.open": "打开终端",
@@ -28,6 +29,7 @@ export default defineMessages(
28
29
  "shell.view.chat": "Chat",
29
30
  "shell.view.agents": "Agents",
30
31
  "shell.view.trace": "Trace",
32
+ "shell.view.traceUpdated": "Trace has new updates",
31
33
  "shell.aria.refreshMessages": "Refresh messages",
32
34
  "shell.terminal.minimize": "Minimize terminal",
33
35
  "shell.terminal.open": "Open terminal",
@@ -678,6 +678,24 @@ button {
678
678
  padding: 0;
679
679
  }
680
680
 
681
+ /* #134: a tab that can carry a quiet unread dot (Trace). Positioned so the dot
682
+ sits at the top-right corner of the icon without affecting layout. */
683
+ .workspace-view-tab--badged {
684
+ position: relative;
685
+ }
686
+
687
+ .workspace-view-tab__badge {
688
+ position: absolute;
689
+ top: 3px;
690
+ right: 3px;
691
+ width: 6px;
692
+ height: 6px;
693
+ border-radius: 999px;
694
+ background: var(--color-danger, #e5484d);
695
+ box-shadow: 0 0 0 2px var(--color-surface-soft);
696
+ pointer-events: none;
697
+ }
698
+
681
699
  .session-title {
682
700
  display: flex;
683
701
  min-width: 0;
@@ -1849,20 +1867,11 @@ button {
1849
1867
  }
1850
1868
 
1851
1869
  @media (max-width: 860px) {
1852
- .desktop-shell {
1853
- grid-template-columns: 64px minmax(0, 1fr);
1854
- }
1855
-
1856
- .sidebar {
1857
- gap: 16px;
1858
- }
1859
-
1860
- .section-heading,
1861
- .conversation-stack,
1862
- .nav-item span {
1863
- display: none;
1864
- }
1865
-
1870
+ /* #131 — the rail layout at narrow widths is now driven by React
1871
+ (DesktopShell auto-collapses below this breakpoint and applies
1872
+ .desktop-shell--sidebar-collapsed), so the sidebar styling lives in those
1873
+ class rules and the session list is reachable via the rail popover instead
1874
+ of being hidden outright. Only non-sidebar responsive tweaks remain here. */
1866
1875
  .prompt-home {
1867
1876
  padding-inline: 18px;
1868
1877
  }
@@ -2091,7 +2100,12 @@ button {
2091
2100
  overscroll-behavior: contain;
2092
2101
  padding: 4px 14px 20px 4px;
2093
2102
  scrollbar-gutter: stable;
2094
- scroll-behavior: smooth;
2103
+ /* #133: NO `scroll-behavior: smooth` here. The chat stream restores its
2104
+ position imperatively (`scrollTop = …`) on tab-switch remount and on
2105
+ pinned-bottom live append; a global smooth behavior turns those instant
2106
+ jumps into a visible top-to-bottom replay through the history. Smooth
2107
+ scrolling, if ever wanted, must be opt-in around an explicit
2108
+ user-triggered jump, never the default for the container. */
2095
2109
  scrollbar-color: var(--color-border) transparent;
2096
2110
  scrollbar-width: thin;
2097
2111
  }
@@ -3013,6 +3027,62 @@ button {
3013
3027
  opacity: 0;
3014
3028
  }
3015
3029
 
3030
+ /* #131 — collapsed icon-rail session popover. The rail hides the inline session
3031
+ section (rule above) and squeezes nav labels to opacity:0; the popover is a
3032
+ floating panel anchored to the Sessions icon that restores the full list. */
3033
+ .sidebar__sessions-popover-anchor {
3034
+ position: relative;
3035
+ }
3036
+
3037
+ .sidebar__sessions-popover {
3038
+ position: absolute;
3039
+ top: 0;
3040
+ left: calc(100% + 10px);
3041
+ z-index: 40;
3042
+ display: flex;
3043
+ width: 300px;
3044
+ max-height: min(70vh, 560px);
3045
+ flex-direction: column;
3046
+ gap: 8px;
3047
+ padding: 12px;
3048
+ border: 1px solid var(--color-border);
3049
+ border-radius: var(--radius-md);
3050
+ background: var(--color-surface-raised, var(--color-surface));
3051
+ box-shadow: var(--shadow-md, 0 12px 32px rgba(0, 0, 0, 0.18));
3052
+ }
3053
+
3054
+ .sidebar__sessions-popover .conversation-stack {
3055
+ display: grid;
3056
+ min-height: 0;
3057
+ overflow-y: auto;
3058
+ }
3059
+
3060
+ .sidebar__sessions-popover-head {
3061
+ display: flex;
3062
+ align-items: center;
3063
+ justify-content: space-between;
3064
+ gap: 8px;
3065
+ }
3066
+
3067
+ .sidebar__sessions-popover-head h2 {
3068
+ margin: 0;
3069
+ color: var(--color-text-subtle);
3070
+ font-size: 13px;
3071
+ font-weight: 500;
3072
+ }
3073
+
3074
+ /* The popover lives inside the collapsed rail, so undo the rail's label
3075
+ squeeze/hide for everything inside it — its content is fully expanded. */
3076
+ .desktop-shell--sidebar-collapsed .sidebar__sessions-popover .nav-item span,
3077
+ .desktop-shell--sidebar-collapsed .sidebar__sessions-popover .conversation-row span,
3078
+ .desktop-shell--sidebar-collapsed .sidebar__sessions-popover .conversation-row small {
3079
+ opacity: 1;
3080
+ }
3081
+
3082
+ .desktop-shell--sidebar-collapsed .sidebar__sessions-popover .conversation-stack {
3083
+ display: grid;
3084
+ }
3085
+
3016
3086
  .sandbox-status {
3017
3087
  position: relative;
3018
3088
  display: inline-flex;