@backmatter/studio 0.0.14 → 0.0.16

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.
Files changed (67) hide show
  1. package/dist/chunks/{UiKitApp-fYeVEf4-.js → UiKitApp-_EDqWID3.js} +2 -2
  2. package/dist/chunks/{WorkspaceRoutes-Dt7JvEWT.js → WorkspaceRoutes-B2PrFgpz.js} +29019 -27476
  3. package/dist/chunks/{dashboard-BuY2aGkO.js → dashboard-BnlnMnnr.js} +55 -54
  4. package/dist/chunks/{index-NWDqEZYW.js → index-D_BHF_8q.js} +1 -1
  5. package/dist/dashboard.js +2 -2
  6. package/dist/index.js +51 -52
  7. package/dist/site-widget.css +4 -0
  8. package/dist/site-widget.js +9419 -9156
  9. package/dist/studio.css +186 -12
  10. package/package.json +2 -2
  11. package/src/WorkspaceRoutes.tsx +5 -2
  12. package/src/api.ts +64 -11
  13. package/src/chat/ChangeCard.tsx +9 -15
  14. package/src/chat/ChatMessage.tsx +172 -103
  15. package/src/chat/ChatPanel.tsx +54 -34
  16. package/src/chat/ChatProvider.tsx +120 -24
  17. package/src/chat/Composer.tsx +48 -7
  18. package/src/chat/DocListCard.tsx +2 -16
  19. package/src/chat/MentionPopover.tsx +8 -9
  20. package/src/chat/PointerChip.tsx +9 -5
  21. package/src/chat/ThreadHistory.tsx +3 -3
  22. package/src/chat/chat-context.ts +74 -10
  23. package/src/chat/chat-helpers.ts +15 -8
  24. package/src/chat/chat-state.ts +11 -0
  25. package/src/chrome/AppShell.tsx +16 -5
  26. package/src/chrome/CommandPalette.tsx +14 -10
  27. package/src/chrome/ContentExplorerSidebar.tsx +23 -3
  28. package/src/chrome/UserMenu.tsx +34 -3
  29. package/src/chrome/WorkspaceStatusBar.tsx +215 -50
  30. package/src/chrome/content-api-url.ts +0 -78
  31. package/src/chrome/content-tree-visibility.ts +13 -3
  32. package/src/chrome/explorer-icons.tsx +5 -0
  33. package/src/code-session-entry.ts +9 -0
  34. package/src/components/WorkspaceSwitcher.tsx +2 -2
  35. package/src/element-origin.ts +149 -0
  36. package/src/host-pointer-bootstrap.ts +11 -0
  37. package/src/host-pointer.ts +147 -0
  38. package/src/install-preview-pointer.ts +19 -3
  39. package/src/pages/CodeSessionFiles.tsx +221 -0
  40. package/src/pages/CodeSessionPage.tsx +1188 -0
  41. package/src/pages/DocEditorPage.tsx +33 -15
  42. package/src/pages/EmbedChatPage.tsx +3 -3
  43. package/src/pages/EmbedPublishPage.tsx +2 -3
  44. package/src/pages/PreviewSize.tsx +181 -0
  45. package/src/pages/SettingsGeneralPage.tsx +67 -2
  46. package/src/pages/WorkspaceHomePage.tsx +313 -0
  47. package/src/pages/WorkspaceLayout.tsx +10 -1
  48. package/src/pages/code-session-boot.ts +43 -0
  49. package/src/pages/code-session-context.tsx +51 -0
  50. package/src/pages/code-session-files.ts +42 -0
  51. package/src/pages/code-session-frame.ts +52 -0
  52. package/src/pages/code-session-routes.ts +42 -0
  53. package/src/pages/home-rows.ts +140 -0
  54. package/src/pages/preview-size.ts +33 -0
  55. package/src/pages/session-preview.ts +109 -0
  56. package/src/pages/type-docs.ts +3 -6
  57. package/src/pages/use-session-preview.ts +70 -0
  58. package/src/replica/store.ts +13 -10
  59. package/src/replica/types.ts +7 -2
  60. package/src/site-pointer.ts +24 -5
  61. package/src/site-widget.tsx +2 -3
  62. package/src/studio-config.ts +1 -1
  63. package/src/ui/ListRow.tsx +12 -3
  64. package/src/ui/Page.tsx +12 -1
  65. package/src/workspace-path.ts +35 -3
  66. package/src/chrome/ContentApiPopover.tsx +0 -215
  67. package/src/pages/WorkspaceHomeRedirect.tsx +0 -17
@@ -1,5 +1,6 @@
1
1
  // packages/studio/src/chat/chat-context.ts
2
2
  import {
3
+ docKey,
3
4
  sameDocRef,
4
5
  type DocRef,
5
6
  type EditorFocus,
@@ -7,13 +8,20 @@ import {
7
8
 
8
9
  /** An element the user pointed at on the site. The agent edits that block, or the quote. */
9
10
  export type ChatPointer = {
10
- locale: string;
11
11
  type: string;
12
12
  slug: string;
13
13
  blockId: string | null;
14
14
  blockType: string | null;
15
15
  blockIndex: number | null;
16
16
  quote: string;
17
+ /** React component that rendered the element, when the dev server exposes it. */
18
+ component?: string | null;
19
+ /** Checkout-relative source path. */
20
+ file?: string | null;
21
+ line?: number | null;
22
+ column?: number | null;
23
+ /** Component names from the element toward the layout. */
24
+ stack?: string[];
17
25
  };
18
26
 
19
27
  export const POINTER_CAP = 8;
@@ -24,15 +32,23 @@ export type ChatRequestContext = {
24
32
  selection: { start: number; end: number } | null;
25
33
  mentions: DocRef[];
26
34
  pointers: ChatPointer[];
35
+ route: string | null;
27
36
  };
28
37
 
38
+ function parseChatRoute(raw: unknown): string | null {
39
+ if (typeof raw !== "string") return null;
40
+ return raw.startsWith("/") && !raw.startsWith("//") ? raw : null;
41
+ }
42
+
29
43
  function samePointer(a: ChatPointer, b: ChatPointer): boolean {
30
44
  return (
31
- a.locale === b.locale &&
32
45
  a.type === b.type &&
33
46
  a.slug === b.slug &&
34
47
  a.blockId === b.blockId &&
35
- a.quote === b.quote
48
+ a.quote === b.quote &&
49
+ (a.component ?? null) === (b.component ?? null) &&
50
+ (a.file ?? null) === (b.file ?? null) &&
51
+ (a.line ?? null) === (b.line ?? null)
36
52
  );
37
53
  }
38
54
 
@@ -50,7 +66,12 @@ export function pointersFromMetadata(metadata: unknown): ChatPointer[] {
50
66
  return pointers.slice(0, POINTER_CAP);
51
67
  }
52
68
 
53
- export function addPointer(list: ChatPointer[], next: ChatPointer): ChatPointer[] {
69
+ export function addPointer(
70
+ list: ChatPointer[],
71
+ next: ChatPointer,
72
+ mode: "append" | "replace" = "append",
73
+ ): ChatPointer[] {
74
+ if (mode === "replace") return [next];
54
75
  if (list.some((item) => samePointer(item, next))) return list;
55
76
  const added = [...list, next];
56
77
  return added.length > POINTER_CAP ? added.slice(added.length - POINTER_CAP) : added;
@@ -61,13 +82,13 @@ export function removePointer(list: ChatPointer[], next: ChatPointer): ChatPoint
61
82
  }
62
83
 
63
84
  export function chatPointerFromGrab(
64
- doc: { locale?: unknown; type?: unknown; slug?: unknown },
85
+ doc: { type?: unknown; slug?: unknown },
65
86
  grab: unknown,
66
87
  ): ChatPointer | null {
67
- if (!doc || typeof doc.locale !== "string" || typeof doc.type !== "string" || typeof doc.slug !== "string") {
88
+ if (!doc || typeof doc.type !== "string" || typeof doc.slug !== "string") {
68
89
  return null;
69
90
  }
70
- if (!doc.locale || !doc.type || !doc.slug) return null;
91
+ if (!doc.type || !doc.slug) return null;
71
92
  if (!grab || typeof grab !== "object") return null;
72
93
  const value = grab as Record<string, unknown>;
73
94
  if (value.blockId !== null && typeof value.blockId !== "string") return null;
@@ -75,21 +96,56 @@ export function chatPointerFromGrab(
75
96
  if (value.blockIndex !== null && typeof value.blockIndex !== "number") return null;
76
97
  if (typeof value.quote !== "string") return null;
77
98
  return {
78
- locale: doc.locale,
79
99
  type: doc.type,
80
100
  slug: doc.slug,
81
101
  blockId: value.blockId,
82
102
  blockType: value.blockType,
83
103
  blockIndex: value.blockIndex,
84
104
  quote: value.quote.replace(/\s+/g, " ").trim().slice(0, 240),
105
+ ...placeFrom(value),
106
+ };
107
+ }
108
+
109
+ function placeFrom(value: Record<string, unknown>): Pick<ChatPointer, "component" | "file" | "line" | "column" | "stack"> {
110
+ const component = typeof value.component === "string" && value.component ? value.component.slice(0, 80) : null;
111
+ const file =
112
+ typeof value.file === "string" && /^[\w./@+-]+\.(?:tsx|ts|jsx|js|mdx)$/.test(value.file) && !value.file.includes("..")
113
+ ? value.file.slice(0, 240)
114
+ : null;
115
+ const line = typeof value.line === "number" && Number.isInteger(value.line) && value.line > 0 ? value.line : null;
116
+ const column = typeof value.column === "number" && Number.isInteger(value.column) && value.column > 0 ? value.column : null;
117
+ const stack = Array.isArray(value.stack)
118
+ ? value.stack.filter((item): item is string => typeof item === "string" && item.length > 0).slice(0, 4)
119
+ : [];
120
+ return {
121
+ ...(component ? { component } : {}),
122
+ ...(file ? { file } : {}),
123
+ ...(line ? { line } : {}),
124
+ ...(column ? { column } : {}),
125
+ ...(stack.length > 0 ? { stack } : {}),
85
126
  };
86
127
  }
87
128
 
129
+ /** A code session sends the route and the pointed blocks, not the open document. */
130
+ export function codeSessionChatContext(input: {
131
+ pointers: ChatPointer[];
132
+ route?: string | null;
133
+ }): ChatRequestContext {
134
+ return buildChatContext({
135
+ focus: null,
136
+ includeFocus: false,
137
+ mentions: [],
138
+ pointers: input.pointers,
139
+ route: input.route,
140
+ });
141
+ }
142
+
88
143
  export function buildChatContext(input: {
89
144
  focus: EditorFocus | null;
90
145
  includeFocus: boolean;
91
146
  mentions: DocRef[];
92
147
  pointers: ChatPointer[];
148
+ route?: string | null;
93
149
  }): ChatRequestContext {
94
150
  const focusDoc = input.includeFocus && input.focus ? input.focus.docRef : null;
95
151
  const selection =
@@ -100,14 +156,20 @@ export function buildChatContext(input: {
100
156
  for (const m of input.mentions) {
101
157
  if (sameDocRef(m, focusDoc)) continue;
102
158
  if (mentions.some((x) => sameDocRef(x, m))) continue;
103
- mentions.push({ locale: m.locale, type: m.type, slug: m.slug });
159
+ mentions.push({ type: m.type, slug: m.slug });
104
160
  }
105
161
  const pointers: ChatPointer[] = [];
106
162
  for (const pointer of input.pointers) {
107
163
  if (pointers.some((item) => samePointer(item, pointer))) continue;
108
164
  pointers.push(pointer);
109
165
  }
110
- return { focusDoc, selection, mentions, pointers: pointers.slice(-POINTER_CAP) };
166
+ return {
167
+ focusDoc,
168
+ selection,
169
+ mentions,
170
+ pointers: pointers.slice(-POINTER_CAP),
171
+ route: parseChatRoute(input.route),
172
+ };
111
173
  }
112
174
 
113
175
  export function addMention(list: DocRef[], ref: DocRef): DocRef[] {
@@ -118,3 +180,5 @@ export function addMention(list: DocRef[], ref: DocRef): DocRef[] {
118
180
  export function removeMention(list: DocRef[], ref: DocRef): DocRef[] {
119
181
  return list.filter((x) => !sameDocRef(x, ref));
120
182
  }
183
+
184
+ export { docKey };
@@ -10,6 +10,18 @@ export type WriteToolOutput =
10
10
  | { ok: true; change?: ChatChangeSummary; changes?: ChatChangeSummary[] }
11
11
  | { ok: false; error: string; message: string };
12
12
 
13
+ export function isCheckoutWriteTool(name: string) {
14
+ return name === "writeFile" || name === "editFile" || name === "deleteFile";
15
+ }
16
+
17
+ /** A finished checkout tool changed the host, so the preview should load again. */
18
+ export function checkoutWriteReloads(name: string, output: unknown): boolean {
19
+ const body = output as { ok?: boolean } | undefined;
20
+ if (isCheckoutWriteTool(name)) return !body || body.ok !== false;
21
+ if (name === "runCommand") return body?.ok === true;
22
+ return false;
23
+ }
24
+
13
25
  export function isWriteTool(name: string) {
14
26
  return (
15
27
  name === "writeDoc" ||
@@ -39,7 +51,6 @@ const TREE_STATUSES = new Set<DocStatus>(["clean", "modified", "added", "conflic
39
51
 
40
52
  /** Statuses a tool result wants applied to the local doc list immediately. */
41
53
  export type TreeStatusPatch = {
42
- locale: string;
43
54
  type: string;
44
55
  slug: string;
45
56
  status: DocStatus;
@@ -55,7 +66,6 @@ export function treeStatusPatches(output: unknown): TreeStatusPatch[] {
55
66
  if (!row || typeof row !== "object") continue;
56
67
  const item = row as Partial<TreeStatusPatch>;
57
68
  if (
58
- typeof item.locale !== "string" ||
59
69
  typeof item.type !== "string" ||
60
70
  !item.type ||
61
71
  typeof item.slug !== "string" ||
@@ -66,7 +76,6 @@ export function treeStatusPatches(output: unknown): TreeStatusPatch[] {
66
76
  continue;
67
77
  }
68
78
  patches.push({
69
- locale: item.locale,
70
79
  type: item.type,
71
80
  slug: item.slug,
72
81
  status: item.status as DocStatus,
@@ -82,7 +91,6 @@ export function detailForTreePatch(
82
91
  patch: TreeStatusPatch,
83
92
  ): {
84
93
  workspaceId: string;
85
- locale: string;
86
94
  type: string;
87
95
  slug: string;
88
96
  status?: TreeStatusPatch["status"];
@@ -91,7 +99,6 @@ export function detailForTreePatch(
91
99
  const gone = patch.absent === true || patch.status === "deleted";
92
100
  return {
93
101
  workspaceId,
94
- locale: patch.locale,
95
102
  type: patch.type,
96
103
  slug: patch.slug,
97
104
  ...(gone ? { remove: true } : { status: patch.status }),
@@ -277,7 +284,7 @@ export function changeIdsInMessage(message: UIMessage): string[] {
277
284
 
278
285
  export type ChangeStackItem = Pick<
279
286
  ChatChangeSummary,
280
- "id" | "locale" | "type" | "slug" | "status" | "createdAt"
287
+ "id" | "type" | "slug" | "status" | "createdAt"
281
288
  >;
282
289
 
283
290
  function isLaterChange(a: ChangeStackItem, b: ChangeStackItem) {
@@ -295,10 +302,10 @@ export function presentedChangeStatus(
295
302
  all: ChangeStackItem[],
296
303
  ): ChatChangeStatus {
297
304
  if (change.status !== "applied") return change.status;
298
- const key = `${change.locale}/${change.type}/${change.slug}`;
305
+ const key = `${change.type}/${change.slug}`;
299
306
  const covered = all.some((other) => {
300
307
  if (other.id === change.id) return false;
301
- if (`${other.locale}/${other.type}/${other.slug}` !== key) return false;
308
+ if (`${other.type}/${other.slug}` !== key) return false;
302
309
  if (other.status !== "applied" && other.status !== "kept") return false;
303
310
  return isLaterChange(other, change);
304
311
  });
@@ -57,3 +57,14 @@ export function panelReducer(state: PanelState, action: PanelAction): PanelState
57
57
  export function isPanelVisible(mode: PanelMode): boolean {
58
58
  return mode === "open" || mode === "fullscreen";
59
59
  }
60
+
61
+ /** A docked column has no window chrome: it cannot close, minimize, or go fullscreen. */
62
+ export function allowsPanelAction(action: PanelAction, docked: boolean): boolean {
63
+ if (!docked) return true;
64
+ return (
65
+ action.type !== "toggle" &&
66
+ action.type !== "close" &&
67
+ action.type !== "minimize" &&
68
+ action.type !== "toggleFullscreen"
69
+ );
70
+ }
@@ -7,17 +7,23 @@ import { ChatPanel } from "../chat/ChatPanel";
7
7
  import { ChatProvider, ChatSessionScope } from "../chat/ChatProvider";
8
8
  import { colors } from "../styles/tokens.stylex";
9
9
  import { TooltipProvider } from "../ui/Tooltip";
10
- import { cmsWorkspaceSlug, isOrgPickerPath } from "../workspace-path";
10
+ import {
11
+ cmsWorkspaceSlug,
12
+ isOrgPickerPath,
13
+ isWorkspaceCodePath,
14
+ isWorkspaceListPath,
15
+ } from "../workspace-path";
11
16
  import { AppSidebar } from "./AppSidebar";
12
17
  import { WorkspaceRail } from "./ContentExplorerSidebar";
13
18
  import { CommandPalette } from "./CommandPalette";
14
- import { FloatingUserMenu } from "./UserMenu";
19
+ import { CornerUserMenu, FloatingUserMenu } from "./UserMenu";
15
20
  import { EditorActionsProvider } from "./EditorActionsContext";
16
21
  import { EditorFocusProvider } from "./EditorFocusContext";
17
22
  import { EditorStatusProvider } from "./EditorStatusContext";
18
23
  import { WorkspaceNavProvider, useWorkspaceNav } from "./WorkspaceNavContext";
19
24
  import { installParentChatHotkey } from "./hotkeys";
20
25
  import { WorkspaceStatusBar } from "./WorkspaceStatusBar";
26
+ import { CodeSessionProvider } from "../pages/code-session-context";
21
27
 
22
28
  export function AppShell({ children }: { children: React.ReactNode }) {
23
29
  return (
@@ -26,7 +32,9 @@ export function AppShell({ children }: { children: React.ReactNode }) {
26
32
  <EditorStatusProvider>
27
33
  <EditorActionsProvider>
28
34
  <EditorFocusProvider>
29
- <AppShellInner>{children}</AppShellInner>
35
+ <CodeSessionProvider>
36
+ <AppShellInner>{children}</AppShellInner>
37
+ </CodeSessionProvider>
30
38
  </EditorFocusProvider>
31
39
  </EditorActionsProvider>
32
40
  </EditorStatusProvider>
@@ -43,6 +51,7 @@ function AppShellInner({ children }: { children: React.ReactNode }) {
43
51
  const workspaceKey = cmsWorkspaceSlug(location.pathname);
44
52
  const workspaceNav = useWorkspaceNav();
45
53
  const orgPicker = isOrgPickerPath(location.pathname);
54
+ const workspaceList = isWorkspaceListPath(location.pathname);
46
55
  const embed = searchParams.get("embed") === "1";
47
56
  const [treeHost, setTreeHost] = useState<HTMLDivElement | null>(null);
48
57
  const inWorkspace = Boolean(workspaceKey && !embed && !orgPicker);
@@ -52,8 +61,9 @@ function AppShellInner({ children }: { children: React.ReactNode }) {
52
61
  return installParentChatHotkey();
53
62
  }, [embed]);
54
63
 
64
+ const codeSession = Boolean(workspaceKey && isWorkspaceCodePath(location.pathname));
55
65
  const chat =
56
- workspaceKey && !embed ? (
66
+ workspaceKey && !embed && !codeSession ? (
57
67
  <ChatSessionScope>
58
68
  <ChatBar />
59
69
  <ChatPanel />
@@ -64,7 +74,7 @@ function AppShellInner({ children }: { children: React.ReactNode }) {
64
74
  <div {...stylex.props(styles.shell)}>
65
75
  {workspaceKey && !embed ? <WorkspaceStatusBar workspaceId={workspaceKey} /> : null}
66
76
  <div {...stylex.props(styles.body)}>
67
- {orgPicker || embed ? null : inWorkspace ? (
77
+ {orgPicker || embed || workspaceList ? null : inWorkspace ? (
68
78
  <WorkspaceRail workspace={workspaceNav} treeHost={treeHost} />
69
79
  ) : (
70
80
  <AppSidebar />
@@ -78,6 +88,7 @@ function AppShellInner({ children }: { children: React.ReactNode }) {
78
88
  </div>
79
89
  </div>
80
90
  {orgPicker ? <FloatingUserMenu /> : null}
91
+ {workspaceList ? <CornerUserMenu /> : null}
81
92
  {embed ? null : <CommandPalette />}
82
93
  </div>
83
94
  );
@@ -27,6 +27,7 @@ import {
27
27
  } from "./hotkeys";
28
28
  import { parsePreviewMessage } from "@backmatter/core/preview";
29
29
  import { useWorkspaceNav } from "./WorkspaceNavContext";
30
+ import { codeSessionEntry } from "../code-session-entry";
30
31
  import {
31
32
  contentDocPath,
32
33
  contentTypePath,
@@ -310,6 +311,17 @@ export function CommandPalette() {
310
311
  });
311
312
  }
312
313
  if (workspaceNav) {
314
+ const codeHref = codeSessionEntry(workspaceNav.role, workspaceNav.workspaceSlug);
315
+ if (codeHref) {
316
+ list.push({
317
+ id: "code-session",
318
+ group: "Go to",
319
+ icon: "document",
320
+ label: "Code session",
321
+ hint: "Host and chat",
322
+ run: go(codeHref),
323
+ });
324
+ }
313
325
  list.push(
314
326
  {
315
327
  id: "settings-general",
@@ -357,7 +369,7 @@ export function CommandPalette() {
357
369
  }
358
370
  for (const doc of docs) {
359
371
  list.push({
360
- id: `doc-${doc.locale}-${doc.type}-${doc.slug}`,
372
+ id: `doc-${doc.type}-${doc.slug}`,
361
373
  group: "Documents",
362
374
  icon: "document",
363
375
  label: `${doc.type}/${doc.slug}`,
@@ -370,15 +382,7 @@ export function CommandPalette() {
370
382
  : doc.status === "modified"
371
383
  ? "Modified"
372
384
  : doc.status,
373
- run: go(
374
- contentDocPath(
375
- workspaceNav.workspaceSlug,
376
- doc.type,
377
- doc.slug,
378
- doc.locale,
379
- workspaceNav.defaultLocale,
380
- ),
381
- ),
385
+ run: go(contentDocPath(workspaceNav.workspaceSlug, doc.type, doc.slug)),
382
386
  });
383
387
  }
384
388
  }
@@ -16,13 +16,14 @@ import { Tooltip } from "../ui/Tooltip";
16
16
  import {
17
17
  contentDocPath,
18
18
  contentRailHref,
19
- isWorkspaceContentPath,
19
+ isWorkspaceCodePath,
20
20
  isWorkspaceEditorPath,
21
21
  isWorkspaceMediaPath,
22
22
  isWorkspaceSettingsPath,
23
23
  mediaPath,
24
24
  settingsPath,
25
25
  } from "../workspace-path";
26
+ import { codeSessionEntry } from "../code-session-entry";
26
27
  import { UserMenu } from "./UserMenu";
27
28
  import {
28
29
  groupContentTypes,
@@ -38,6 +39,7 @@ import {
38
39
  ExplorerHeaderListIcon,
39
40
  ExplorerHeaderNewFileIcon,
40
41
  ExplorerTreeNewFileIcon,
42
+ ExplorerRailCodeIcon,
41
43
  ExplorerRailFilesIcon,
42
44
  ExplorerRailHistoryIcon,
43
45
  ExplorerRailMediaIcon,
@@ -62,6 +64,7 @@ import {
62
64
  } from "./folder-create-context";
63
65
  import { canWriteContent } from "../workspace/permissions";
64
66
  import {
67
+ contentRailCurrent,
65
68
  contentTreeVisible,
66
69
  toggleContentTree,
67
70
  useContentTreeOpen,
@@ -264,6 +267,7 @@ function ContentExplorerSidebar({
264
267
  pendingCount={pendingCount}
265
268
  settingsActive={settingsActive}
266
269
  workspaceSlug={workspace.workspaceSlug}
270
+ role={workspace.role}
267
271
  treeOpen={treeOpen}
268
272
  />
269
273
  {tree && treeHost ? createPortal(tree, treeHost) : null}
@@ -271,22 +275,26 @@ function ContentExplorerSidebar({
271
275
  );
272
276
  }
273
277
 
274
- function ActivityRail({
278
+ export function ActivityRail({
275
279
  pendingCount,
276
280
  settingsActive,
277
281
  workspaceSlug,
282
+ role,
278
283
  treeOpen,
279
284
  }: {
280
285
  pendingCount: number;
281
286
  settingsActive: boolean;
282
287
  workspaceSlug: string;
288
+ role: string;
283
289
  treeOpen: boolean;
284
290
  }) {
285
291
  const navigate = useNavigate();
286
292
  const location = useLocation();
287
293
  const onDocument = isWorkspaceEditorPath(location.pathname);
288
294
  const filesLabel = onDocument ? (treeOpen ? "Hide files" : "Show files") : "Content";
289
- const filesActive = isWorkspaceContentPath(location.pathname) && (!onDocument || treeOpen);
295
+ const filesActive = contentRailCurrent(location.pathname, treeOpen);
296
+ const codeHref = codeSessionEntry(role, workspaceSlug);
297
+ const codeActive = isWorkspaceCodePath(location.pathname);
290
298
 
291
299
  return (
292
300
  <nav
@@ -351,6 +359,18 @@ function ActivityRail({
351
359
  <ExplorerRailMediaIcon />
352
360
  </Link>
353
361
  </Tooltip>
362
+ {codeHref ? (
363
+ <Tooltip label="Code session" side="right">
364
+ <Link
365
+ to={codeHref}
366
+ aria-label="Code session"
367
+ aria-current={codeActive ? "page" : undefined}
368
+ {...stylex.props(styles.railBtn, codeActive && styles.railBtnActive)}
369
+ >
370
+ <ExplorerRailCodeIcon />
371
+ </Link>
372
+ </Tooltip>
373
+ ) : null}
354
374
  </div>
355
375
  <div {...stylex.props(styles.railBottom)}>
356
376
  <Tooltip label="Settings" side="right">
@@ -16,10 +16,28 @@ export function FloatingUserMenu() {
16
16
  );
17
17
  }
18
18
 
19
- export function UserMenu({ quiet = false, compact = false }: { quiet?: boolean; compact?: boolean }) {
19
+ /** Small account control for full-screen pages that have no sidebar. */
20
+ export function CornerUserMenu() {
21
+ return (
22
+ <div {...stylex.props(styles.corner)}>
23
+ <UserMenu quiet placement="below-end" />
24
+ </div>
25
+ );
26
+ }
27
+
28
+ export function UserMenu({
29
+ quiet = false,
30
+ compact = false,
31
+ placement = "above-start",
32
+ }: {
33
+ quiet?: boolean;
34
+ compact?: boolean;
35
+ placement?: "above-start" | "below-end";
36
+ }) {
20
37
  const { email, onLogout } = useAppChrome();
21
38
  const location = useLocation();
22
39
  const onAccount = location.pathname.replace(/\/$/, "") === "/account";
40
+ const belowEnd = placement === "below-end";
23
41
 
24
42
  return (
25
43
  <Menu.Root>
@@ -29,6 +47,7 @@ export function UserMenu({ quiet = false, compact = false }: { quiet?: boolean;
29
47
  styles.trigger,
30
48
  quiet && styles.triggerQuiet,
31
49
  compact && styles.triggerCompact,
50
+ belowEnd && styles.triggerCorner,
32
51
  onAccount && styles.triggerActive,
33
52
  )}
34
53
  >
@@ -40,8 +59,8 @@ export function UserMenu({ quiet = false, compact = false }: { quiet?: boolean;
40
59
  </Menu.Trigger>
41
60
  <Menu.Portal>
42
61
  <Menu.Positioner
43
- side="top"
44
- align={compact ? "center" : "start"}
62
+ side={belowEnd ? "bottom" : "top"}
63
+ align={belowEnd ? "end" : compact ? "center" : "start"}
45
64
  sideOffset={6}
46
65
  className={cx(popup.positioner)}
47
66
  >
@@ -81,6 +100,13 @@ const styles = stylex.create({
81
100
  zIndex: z.dropdown,
82
101
  maxWidth: "220px",
83
102
  },
103
+ corner: {
104
+ position: "fixed",
105
+ top: space.lg,
106
+ right: space.lg,
107
+ zIndex: z.dropdown,
108
+ maxWidth: "240px",
109
+ },
84
110
  trigger: {
85
111
  display: "flex",
86
112
  width: "100%",
@@ -128,6 +154,11 @@ const styles = stylex.create({
128
154
  fontSize: type.sm,
129
155
  fontWeight: 500,
130
156
  },
157
+ triggerCorner: {
158
+ minHeight: "44px",
159
+ justifyContent: "flex-end",
160
+ textAlign: "right",
161
+ },
131
162
  triggerActive: {
132
163
  backgroundColor: colors.bgActive,
133
164
  },