cli-jaw 2.17.63 → 2.17.64

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 (52) hide show
  1. package/dist/src/code-mode/store.js +35 -6
  2. package/dist/src/code-mode/store.js.map +1 -1
  3. package/dist/src/routes/code-native.js +11 -2
  4. package/dist/src/routes/code-native.js.map +1 -1
  5. package/package.json +1 -1
  6. package/public/dist/.vite/manifest.json +21 -9
  7. package/public/dist/assets/CodeCanvas-c_omHvtV.js +3 -0
  8. package/public/dist/assets/FolderPanel-DOLT_syh.js +3 -0
  9. package/public/dist/assets/{FolderPanel-BHEYcjea.css → FolderPanel-DgoqN1cX.css} +1 -1
  10. package/public/dist/assets/icons-QgChTDzZ.js +1 -0
  11. package/public/dist/assets/manager-Bq8-tr-M.js +5 -0
  12. package/public/dist/assets/manager-C6UJc985.css +1 -0
  13. package/public/dist/manager/index.html +2 -2
  14. package/public/manager/src/App.tsx +3 -3
  15. package/public/manager/src/AppChrome.tsx +1 -1
  16. package/public/manager/src/background-tasks/BackgroundTaskMonitorPanel.tsx +41 -7
  17. package/public/manager/src/background-tasks/background-task-monitor.css +1 -4
  18. package/public/manager/src/code/CodeSessionList.tsx +95 -35
  19. package/public/manager/src/code/code-controller-runtime.ts +66 -8
  20. package/public/manager/src/code/code-controller-types.ts +4 -0
  21. package/public/manager/src/code/code-session-client.ts +1 -1
  22. package/public/manager/src/code/code-session-sidebar.css +32 -0
  23. package/public/manager/src/code/code.css +2 -4
  24. package/public/manager/src/code/session-order.ts +9 -0
  25. package/public/manager/src/components/CommandBar.tsx +0 -10
  26. package/public/manager/src/components/InstanceGroups.tsx +2 -0
  27. package/public/manager/src/components/InstanceListContent.tsx +2 -0
  28. package/public/manager/src/components/InstanceRow.tsx +146 -89
  29. package/public/manager/src/components/SidebarRail.tsx +6 -5
  30. package/public/manager/src/components/ThemeSwitch.tsx +46 -22
  31. package/public/manager/src/components/context-menu/ContextMenu.tsx +207 -0
  32. package/public/manager/src/components/context-menu/context-menu.css +111 -0
  33. package/public/manager/src/components/context-menu/icons.tsx +48 -0
  34. package/public/manager/src/folder-panel/FolderContextMenu.tsx +43 -17
  35. package/public/manager/src/folder-panel/FolderPanel.tsx +9 -1
  36. package/public/manager/src/folder-panel/FolderPanelOverlays.tsx +2 -0
  37. package/public/manager/src/folder-panel/FolderTreeRows.tsx +6 -1
  38. package/public/manager/src/folder-panel/folder-panel.css +0 -33
  39. package/public/manager/src/folder-panel/use-folder-context-menu.ts +6 -19
  40. package/public/manager/src/hooks/useDashboardRegistry.ts +24 -1
  41. package/public/manager/src/jaw-ceo/JawCeoConsolePanels.tsx +71 -9
  42. package/public/manager/src/jaw-ceo/jaw-ceo-console.css +5 -7
  43. package/public/manager/src/main.tsx +2 -0
  44. package/public/manager/src/manager-components.css +1 -34
  45. package/public/manager/src/manager-desktop-titlebar.css +117 -0
  46. package/public/manager/src/manager-layout.css +0 -1
  47. package/public/manager/src/manager-p0-1-1.css +0 -42
  48. package/public/manager/src/manager-polish.css +0 -88
  49. package/public/dist/assets/CodeCanvas-CVbdp0S-.js +0 -3
  50. package/public/dist/assets/FolderPanel-CJvwRwIV.js +0 -3
  51. package/public/dist/assets/manager-CCXm5Yfe.js +0 -5
  52. package/public/dist/assets/manager-D9rhKFKc.css +0 -1
@@ -21,7 +21,7 @@ import { type ScheduleGroup } from './dashboard-schedule/DashboardScheduleSideba
21
21
  import { normalizeSidebarModeForBuild, REMINDERS_WORKSPACE_ENABLED } from './dashboard-features';
22
22
  import { readInitialSelectedPort, readInitialSidebarMode, readTrayRemindersMode } from './dashboard-url-state';
23
23
  import { TrayRemindersApp } from './dashboard-reminders/TrayRemindersApp';
24
- import { useDashboardRegistry } from './hooks/useDashboardRegistry';
24
+ import { useDashboardRegistry, useFavoriteToggle } from './hooks/useDashboardRegistry';
25
25
  import { useDashboardView, hydrateInstanceSettings, createInstanceSettingsNavigation, useSettingsDirtyState } from './hooks/useDashboardView';
26
26
  import { useActivityUnread } from './hooks/useActivityUnread';
27
27
  import { useTheme } from './hooks/useTheme';
@@ -470,7 +470,7 @@ export function App() { if (readTrayRemindersMode(window.location.search) && REM
470
470
  setTransitionAction(null);
471
471
  }
472
472
  }
473
-
473
+ const toggleFavorite = useFavoriteToggle({ save: registry.save, reload: load });
474
474
  const instanceListContent = (
475
475
  <InstanceListContent error={error} loading={loading} instances={instances} filtered={filtered}
476
476
  selectedInstance={selectedInstance} data={data} lifecycleBusyPort={lifecycleBusyPort}
@@ -481,7 +481,7 @@ export function App() { if (readTrayRemindersMode(window.location.search) && REM
481
481
  showSelectedRowActions={view.showSelectedRowActions} profiles={profiles} getLabel={instanceLabel}
482
482
  formatUptime={formatUptime} onSelect={handleSelectInstance} onPreview={(instance) => handlePreview(instance, true)}
483
483
  onMarkActivitySeen={activityUnread.markPortSeen} onInstanceLabelSave={labelEditor.saveInstanceLabel}
484
- onLifecycle={(action, instance) => void handleLifecycle(action, instance)} />
484
+ onLifecycle={(action, instance) => void handleLifecycle(action, instance)} onToggleFavorite={toggleFavorite} />
485
485
  );
486
486
  const dashboardSettingsUi = dashboardSettingsUiFromView(view, theme.theme), titleSupport = summarizeActivityTitleSupport(messageActivity.titleSupportByPort);
487
487
  const profileChipStrip = (chipProfiles: DashboardProfile[]) => chipProfiles.length > 0 ? <div className="profile-chip-strip drawer-chip-strip" aria-label="Profile filters">{chipProfiles.map(profile => <ProfileChip key={profile.profileId} profile={profile} active={activeProfileIds.includes(profile.profileId)} count={profileCounts[profile.profileId] || 0} onToggle={toggleProfile} />)}</div> : null;
@@ -129,7 +129,7 @@ export function AppChrome(props: AppChromeProps) {
129
129
  <PanelLayoutProvider initialPanelState={props.panelInitialState} onStateChange={props.onPanelStateChange}>
130
130
  <ManagerShell
131
131
  sidebarCollapsed={props.view.sidebarCollapsed}
132
- commandBar={<CommandBar query={props.query} loading={props.loading} onQueryChange={props.setQuery} onRefresh={() => void props.load()} onOpenDrawer={() => props.view.setDrawerOpen(true)} theme={props.theme.theme} onThemeChange={props.theme.setTheme} onOpenPalette={props.palette.toggle} />}
132
+ commandBar={<CommandBar query={props.query} loading={props.loading} onQueryChange={props.setQuery} onRefresh={() => void props.load()} onOpenDrawer={() => props.view.setDrawerOpen(true)} theme={props.theme.theme} onThemeChange={props.theme.setTheme} />}
133
133
  workspace={(
134
134
  <SidebarRailRouter sidebarCollapsed={props.view.sidebarCollapsed} activityDockCollapsed={props.view.activityDockCollapsed}
135
135
  activityDockHeight={props.view.activityDockHeight} drawerOpen={props.view.drawerOpen} onCloseDrawer={() => props.view.setDrawerOpen(false)}
@@ -1,4 +1,6 @@
1
1
  import { useMemo, useState } from 'react';
2
+ import { ContextMenu, useContextMenu, type ContextMenuEntry } from '../components/context-menu/ContextMenu';
3
+ import { CopyGlyph, EyeGlyph, RestartGlyph, StopGlyph } from '../components/context-menu/icons';
2
4
  import type { BackgroundTaskRow, BackgroundTaskStatus } from './background-task-client';
3
5
  import { countBackgroundTasksByStatus, useBackgroundTasks } from './useBackgroundTasks';
4
6
 
@@ -71,23 +73,54 @@ type BackgroundTaskRowProps = {
71
73
  };
72
74
 
73
75
  function BackgroundTaskItem({ task, expanded, busy, onCancel, onCopy, onRetry, onToggle }: BackgroundTaskRowProps) {
76
+ const menu = useContextMenu();
74
77
  const preview = resultPreview(task);
75
78
  const isRunning = task.status === 'running';
76
79
  const canRetry = task.status !== 'running';
77
80
  const note = recoveryNote(task);
81
+ const menuEntries: ContextMenuEntry[] = [
82
+ { id: 'details', label: expanded ? 'Hide details' : 'Show details', icon: <EyeGlyph />, onSelect: () => onToggle(task.id) },
83
+ { kind: 'separator', id: 'sep-actions' },
84
+ { id: 'retry', label: 'Retry', icon: <RestartGlyph />, disabled: !canRetry || busy, onSelect: () => onRetry(task) },
85
+ { id: 'cancel', label: 'Cancel', icon: <StopGlyph />, disabled: !isRunning || busy, onSelect: () => onCancel(task.id) },
86
+ { kind: 'separator', id: 'sep-copy' },
87
+ { id: 'copy-result', label: 'Copy result', icon: <CopyGlyph />, disabled: !task.result || busy, onSelect: () => onCopy(task) },
88
+ ];
78
89
  return (
79
- <li className={`code-bg-task-item is-${task.status}`}>
80
- <button type="button" className="code-bg-task-main" onClick={() => onToggle(task.id)} aria-expanded={expanded}>
90
+ <li className={`code-bg-task-item is-${task.status}`} onContextMenu={menu.openAt}>
91
+ <button
92
+ type="button"
93
+ className="code-bg-task-main"
94
+ onClick={() => onToggle(task.id)}
95
+ aria-expanded={expanded}
96
+ onKeyDown={event => {
97
+ if (event.key === 'ContextMenu' || (event.shiftKey && event.key === 'F10')) menu.openAt(event);
98
+ }}
99
+ >
81
100
  <span className={`code-bg-task-status is-${task.status}`}>{STATUS_LABEL[task.status]}</span>
82
101
  <span className="code-bg-task-title">{task.id}</span>
83
102
  <span className="code-bg-task-meta">{taskSubtitle(task)}</span>
84
103
  </button>
85
- <div className="code-bg-task-actions">
86
- <button type="button" onClick={() => onRetry(task)} disabled={!canRetry || busy} title="Run this background task spec again">
87
- Retry
104
+ <div className="code-bg-task-actions jaw-row-hover-actions">
105
+ <button
106
+ type="button"
107
+ className="jaw-row-icon-btn"
108
+ onClick={() => onRetry(task)}
109
+ disabled={!canRetry || busy}
110
+ title="Run this background task spec again"
111
+ aria-label={`Retry ${task.id}`}
112
+ >
113
+ <RestartGlyph />
88
114
  </button>
89
- <button type="button" onClick={() => onCancel(task.id)} disabled={!isRunning || busy} title="Cancel running background task">
90
- Cancel
115
+ <button
116
+ type="button"
117
+ className="jaw-row-icon-btn"
118
+ onClick={() => onCancel(task.id)}
119
+ disabled={!isRunning || busy}
120
+ title="Cancel running background task"
121
+ aria-label={`Cancel ${task.id}`}
122
+ >
123
+ <StopGlyph />
91
124
  </button>
92
125
  </div>
93
126
  {expanded && (
@@ -122,6 +155,7 @@ function BackgroundTaskItem({ task, expanded, busy, onCancel, onCopy, onRetry, o
122
155
  ) : null}
123
156
  </div>
124
157
  )}
158
+ <ContextMenu state={menu.state} entries={menuEntries} label={`Background task ${task.id}`} onClose={menu.close} />
125
159
  </li>
126
160
  );
127
161
  }
@@ -60,7 +60,6 @@
60
60
  }
61
61
 
62
62
  .code-bg-task-refresh,
63
- .code-bg-task-actions button,
64
63
  .code-bg-task-result-head button {
65
64
  min-height: 24px;
66
65
  padding: 0 8px;
@@ -75,7 +74,6 @@
75
74
  }
76
75
 
77
76
  .code-bg-task-refresh:disabled,
78
- .code-bg-task-actions button:disabled,
79
77
  .code-bg-task-result-head button:disabled {
80
78
  opacity: 0.45;
81
79
  cursor: not-allowed;
@@ -176,8 +174,7 @@
176
174
  }
177
175
 
178
176
  .code-bg-task-actions {
179
- display: flex;
180
- gap: 5px;
177
+ justify-content: flex-end;
181
178
  }
182
179
 
183
180
  .code-bg-task-detail {
@@ -1,12 +1,16 @@
1
1
  import { useEffect, useId, useRef, useState } from 'react';
2
+ import type { KeyboardEvent as ReactKeyboardEvent } from 'react';
2
3
  import type { CodeSessionInfo } from '../../../../src/code-mode/wire';
3
4
  import type { CodeControllerModel } from './code-controller-types';
4
5
  import { CODE_RUNTIME_LABELS, CODE_SESSION_LABELS, codeCanResume, codeSessionBusy } from './code-types';
5
6
  import {
6
7
  CODE_ACTIVITY_BUCKET_LABELS, codeSessionAttention, codeSessionAttentionLabel, codeSessionUnread, codeWorkspaceName,
7
- groupCodeSessions, groupCodeSessionsByActivity, groupCodeSessionsByWorkspace,
8
+ comparePinnedCodeSessions, groupCodeSessions, groupCodeSessionsByActivity, groupCodeSessionsByWorkspace,
8
9
  } from './session-order';
9
10
  import { DEFAULT_MANAGER_SHORTCUT_KEYMAP, formatShortcut } from '../manager-shortcuts';
11
+ import { ContextMenu, useContextMenu, type ContextMenuEntry } from '../components/context-menu/ContextMenu';
12
+ import { ArchiveGlyph, CopyGlyph, EyeGlyph, PencilGlyph, PinGlyph, PlayGlyph } from '../components/context-menu/icons';
13
+ import { copyText } from '../clipboard/copy-text';
10
14
 
11
15
  const NEW_SESSION_SHORTCUT = DEFAULT_MANAGER_SHORTCUT_KEYMAP.newCodeSession;
12
16
 
@@ -43,16 +47,22 @@ function SessionRow({ session: s, controller: c, view }: { session: CodeSessionI
43
47
  const [title, setTitle] = useState(s.title ?? '');
44
48
  const [pending, setPending] = useState(false);
45
49
  const [error, setError] = useState<string | null>(null);
50
+ const menu = useContextMenu();
46
51
  const guard = useRef(false);
47
52
  const selectButton = useRef<HTMLButtonElement>(null);
48
53
  const errorId = useId();
49
54
  const active = c.selectedId === s.sessionId;
50
55
  const busy = codeSessionBusy(s);
56
+ const archived = s.archivedAt !== null;
57
+ const pinned = s.pinnedAt !== null;
51
58
  const count = active && c.synced ? c.permissions.length : s.pendingPermissionCount;
52
59
  const attention = codeSessionAttention(count);
53
- const unread = !active && codeSessionUnread(s);
60
+ // An archived row never lights the completion-based unread rule, but an
61
+ // explicit mark still counts so it can be toggled back off.
62
+ const unread = !active && (s.markedUnread || codeSessionUnread(s));
54
63
  const working = c.workingIds?.has(s.sessionId) ?? false;
55
64
  const waiting = working && s.status === 'idle';
65
+ const name = s.title || 'Untitled session';
56
66
  async function action(run: () => Promise<void>, after?: () => void) {
57
67
  if (guard.current) return;
58
68
  guard.current = true; setPending(true); setError(null);
@@ -61,27 +71,85 @@ function SessionRow({ session: s, controller: c, view }: { session: CodeSessionI
61
71
  finally { guard.current = false; setPending(false); }
62
72
  }
63
73
  function finishRename() { setRenaming(false); selectButton.current?.focus(); }
64
- return <li className="code-session-row">
65
- <button ref={selectButton} type="button" className={`code-session-item${active ? ' active' : ''}`}
66
- aria-current={active ? 'true' : undefined} onClick={() => void action(() => c.selectSession(s.sessionId))}>
67
- <span className="code-session-cwd">
68
- {unread && <span className="code-session-unread-dot" role="img" aria-label="Unread" />}
69
- {s.title || 'Untitled session'}
74
+ async function copy(value: string) {
75
+ const result = await copyText(value);
76
+ if (!result.ok) throw new Error(result.error ?? 'Copy failed.');
77
+ }
78
+ const entries: ContextMenuEntry[] = [
79
+ { id: 'rename', label: 'Rename', icon: <PencilGlyph />, disabled: pending || busy,
80
+ ...(busy ? { title: 'Stop before renaming' } : {}),
81
+ onSelect: () => { setTitle(s.title ?? ''); setRenaming(true); } },
82
+ { id: 'pin', label: pinned ? 'Unpin' : 'Pin', icon: <PinGlyph />, disabled: pending,
83
+ onSelect: () => void action(() => c.pin(s.sessionId, !pinned)) },
84
+ { id: 'unread', label: unread ? 'Mark as read' : 'Mark as unread', icon: <EyeGlyph />, disabled: pending || active,
85
+ ...(active ? { title: 'The open session is already read' } : {}),
86
+ onSelect: () => void action(() => c.markUnread(s.sessionId, !unread)) },
87
+ { id: 'archive', label: archived ? 'Restore' : 'Archive', icon: <ArchiveGlyph />, disabled: pending || busy,
88
+ ...(busy ? { title: 'Stop before archiving' } : {}),
89
+ onSelect: () => void action(() => c.archive(s.sessionId, !archived)) },
90
+ ...(codeCanResume(s) ? [
91
+ { kind: 'separator', id: 'resume-sep' } as const,
92
+ { id: 'resume', label: 'Resume', icon: <PlayGlyph />, disabled: pending || !active || !c.synced || c.pending,
93
+ title: !active ? 'Select this session to resume it' : 'Resume without resending a prompt',
94
+ onSelect: () => void action(c.resume) },
95
+ ] : []),
96
+ { kind: 'separator', id: 'copy-sep' },
97
+ { id: 'copy-id', label: 'Copy session ID', icon: <CopyGlyph />,
98
+ onSelect: () => void action(() => copy(s.sessionId)) },
99
+ { id: 'copy-cwd', label: 'Copy working directory', icon: <CopyGlyph />,
100
+ onSelect: () => void action(() => copy(s.cwd)) },
101
+ ];
102
+ function openMenuFromKey(event: ReactKeyboardEvent<HTMLElement>) {
103
+ if (event.key === 'ContextMenu' || (event.shiftKey && event.key === 'F10')) menu.openAt(event);
104
+ }
105
+ return <li className="code-session-row" onContextMenu={event => {
106
+ if ((event.target as Element).closest('.code-session-rename')) return;
107
+ menu.openAt(event);
108
+ }}>
109
+ <div className="code-session-item-wrap" onKeyDown={openMenuFromKey}>
110
+ <button ref={selectButton} type="button" className={`code-session-item${active ? ' active' : ''}`}
111
+ aria-current={active ? 'true' : undefined}
112
+ onClick={() => void action(() => c.selectSession(s.sessionId))}>
113
+ <span className="code-session-cwd">
114
+ {unread && <span className="code-session-unread-dot" role="img" aria-label="Unread" />}
115
+ {name}
116
+ </span>
117
+ {/* Projects view already names the workspace in the group heading. */}
118
+ <span className="code-session-meta" title={s.cwd}>{view === 'activity'
119
+ ? `${codeWorkspaceName(s.cwd)} · ${CODE_RUNTIME_LABELS[s.provider]}` : CODE_RUNTIME_LABELS[s.provider]}</span>
120
+ {/* Ready is the common case and stays silent. A status on every row
121
+ is a status on no row, and it costs the one session that is
122
+ actually doing something its visibility. */}
123
+ <span className={`code-session-status code-session-status-${waiting ? 'starting' : s.status}`}
124
+ data-quiet={s.status === 'idle' && !working ? 'true' : undefined}>
125
+ {working && <span className="code-session-spinner" aria-hidden="true" />}
126
+ {waiting ? CODE_SESSION_LABELS.starting : CODE_SESSION_LABELS[s.status]}</span>
127
+ <span className={`code-session-attention code-session-attention-${attention.kind}`}
128
+ data-quiet={attention.kind === 'none' ? 'true' : undefined}>{codeSessionAttentionLabel(attention)}</span>
129
+ </button>
130
+ {/* Row actions replace the status text on hover or keyboard focus,
131
+ Codex-style; opacity keeps them tabbable instead of display:none. */}
132
+ <span className="code-session-hover-actions">
133
+ <button type="button" className={`code-session-hover-btn${pinned ? ' is-on' : ''}`}
134
+ aria-label={pinned ? `Unpin ${name}` : `Pin ${name}`} aria-pressed={pinned}
135
+ title={pinned ? 'Unpin' : 'Pin'} disabled={pending}
136
+ onClick={event => { event.stopPropagation(); void action(() => c.pin(s.sessionId, !pinned)); }}>
137
+ <PinGlyph />
138
+ </button>
139
+ <button type="button" className="code-session-hover-btn"
140
+ aria-label={archived ? `Restore ${name}` : `Archive ${name}`}
141
+ title={busy ? 'Stop before archiving' : archived ? 'Restore' : 'Archive'} disabled={pending || busy}
142
+ onClick={event => { event.stopPropagation(); void action(() => c.archive(s.sessionId, !archived)); }}>
143
+ <ArchiveGlyph />
144
+ </button>
145
+ <button type="button" className="code-session-hover-btn"
146
+ aria-label={`More actions for ${name}`} aria-haspopup="menu" title="More actions"
147
+ onClick={event => { event.stopPropagation(); menu.openAtElement(event.currentTarget); }}>
148
+ <span aria-hidden="true">⋯</span>
149
+ </button>
70
150
  </span>
71
- {/* Projects view already names the workspace in the group heading. */}
72
- <span className="code-session-meta" title={s.cwd}>{view === 'activity'
73
- ? `${codeWorkspaceName(s.cwd)} · ${CODE_RUNTIME_LABELS[s.provider]}` : CODE_RUNTIME_LABELS[s.provider]}</span>
74
- {/* Ready is the common case and stays silent. A status on every row
75
- is a status on no row, and it costs the one session that is
76
- actually doing something its visibility. */}
77
- <span className={`code-session-status code-session-status-${waiting ? 'starting' : s.status}`}
78
- data-quiet={s.status === 'idle' && !working ? 'true' : undefined}>
79
- {working && <span className="code-session-spinner" aria-hidden="true" />}
80
- {waiting ? CODE_SESSION_LABELS.starting : CODE_SESSION_LABELS[s.status]}</span>
81
- <span className={`code-session-attention code-session-attention-${attention.kind}`}
82
- data-quiet={attention.kind === 'none' ? 'true' : undefined}>{codeSessionAttentionLabel(attention)}</span>
83
- </button>
84
- {renaming ? <form className="code-session-rename" onSubmit={event => {
151
+ </div>
152
+ {renaming && <form className="code-session-rename" onSubmit={event => {
85
153
  event.preventDefault(); if (title.trim()) void action(() => c.rename(s.sessionId, title.trim()), finishRename);
86
154
  }}>
87
155
  <input autoFocus aria-label="Session title" aria-describedby={error ? errorId : undefined} value={title} disabled={pending}
@@ -90,18 +158,8 @@ function SessionRow({ session: s, controller: c, view }: { session: CodeSessionI
90
158
  }} />
91
159
  <button type="submit" disabled={pending || !title.trim()}>Save</button>
92
160
  <button type="button" disabled={pending} onClick={finishRename}>Cancel</button>
93
- </form> : <details className="code-session-actions" key={`${c.selectedId}:${s.sessionId}`}>
94
- <summary aria-label={`Actions for ${s.title || 'Untitled session'}`}>Actions</summary>
95
- <div>
96
- <button type="button" disabled={pending || busy} onClick={() => { setTitle(s.title ?? ''); setRenaming(true); }}>Rename</button>
97
- <button type="button" disabled={pending || busy} title={busy ? 'Stop before archiving' : undefined}
98
- onClick={() => void action(() => c.archive(s.sessionId, s.archivedAt === null))}>{s.archivedAt === null ? 'Archive' : 'Restore'}</button>
99
- {codeCanResume(s) && <button type="button" disabled={pending || !active || !c.synced || c.pending}
100
- title={!active ? 'Select this session to resume it' : 'Resume without resending a prompt'}
101
- onClick={() => void action(c.resume)}>Resume</button>}
102
- {busy && <small>Stop before changing session metadata.</small>}
103
- </div>
104
- </details>}
161
+ </form>}
162
+ <ContextMenu state={menu.state} entries={entries} label={`Actions for ${name}`} onClose={menu.close} />
105
163
  {pending && <span className="code-session-view-hint" role="status">Updating session…</span>}
106
164
  {error && <div id={errorId} className="code-session-list-error" role="alert">{error}</div>}
107
165
  </li>;
@@ -131,12 +189,14 @@ export function CodeSessionList({ controller: c, newSessionShortcut = NEW_SESSIO
131
189
  // answering a prompt never moves a row under the reader's cursor. Activity is the
132
190
  // reader's explicit choice: unread first, then last activity by day.
133
191
  const anyUnread = c.sessions.some(row => row.sessionId !== c.selectedId && codeSessionUnread(row));
134
- const live = visible.filter(row => row.archivedAt === null);
192
+ const pinned = visible.filter(row => row.archivedAt === null && row.pinnedAt !== null).sort(comparePinnedCodeSessions);
193
+ const live = visible.filter(row => row.archivedAt === null && row.pinnedAt === null);
135
194
  const archived = visible.filter(row => row.archivedAt !== null);
136
195
  const groups: { key: string; title: string; cwd: string | null; sessions: CodeSessionInfo[] }[] = view === 'activity'
137
196
  ? groupCodeSessionsByActivity(live, Date.now(), c.selectedId).map(group => ({
138
197
  key: group.bucket, title: CODE_ACTIVITY_BUCKET_LABELS[group.bucket], cwd: null, sessions: group.sessions }))
139
198
  : groupCodeSessionsByWorkspace(live).map(group => ({ key: group.cwd, title: group.cwd, cwd: group.cwd, sessions: group.sessions }));
199
+ if (pinned.length) groups.unshift({ key: 'pinned', title: 'Pinned', cwd: null, sessions: pinned });
140
200
  if (archived.length) groups.push({ key: 'archived', title: 'Archived', cwd: null, sessions: groupCodeSessions(archived).flatMap(group => group.sessions) });
141
201
  // The chord resolves through the manager shortcut system (rebindable in
142
202
  // Settings); the runner re-broadcasts it here so Code mode can be absent
@@ -87,6 +87,7 @@ export class CodeController {
87
87
  private book: CodeDraftBook;
88
88
  private details = new Map<string, CodeSessionState>();
89
89
  private summaries = new Map<string, CodeSessionInfo>();
90
+ private pinnedOverlay: CodeSessionInfo[] = [];
90
91
  private attention = new Map<string, CodeSessionAttention>();
91
92
  private rows: string[] = [];
92
93
  private catalog: CodeModelCatalog | null = null;
@@ -162,6 +163,7 @@ export class CodeController {
162
163
  this.book.listeners.delete(this.changed);
163
164
  this.details.clear();
164
165
  this.summaries.clear();
166
+ this.pinnedOverlay = [];
165
167
  this.attention.clear();
166
168
  this.rows = [];
167
169
  this.cursor = null;
@@ -196,7 +198,9 @@ export class CodeController {
196
198
  if (!id) return null;
197
199
  const detail = this.details.get(id)?.session;
198
200
  const summary = this.summaries.get(id);
199
- return summary && newer(summary, detail) ? summary : detail ?? summary ?? null;
201
+ const overlay = this.pinnedOverlay.find(row => row.sessionId === id);
202
+ return [detail, summary, overlay].reduce<CodeSessionInfo | null>((best, row) =>
203
+ row && newer(row, best) ? row : best, null);
200
204
  }
201
205
  /**
202
206
  * Record what an owning read saw. Call this BEFORE accept() on the same object:
@@ -236,9 +240,15 @@ export class CodeController {
236
240
  // observe(), and update() hands over details.session, which the reducer may
237
241
  // still be holding (code-session-state.ts snapshot replace).
238
242
  const { contextUsage: _usage, pendingPermissionCount: _count, ...session } = incoming;
243
+ const overlayIndex = this.pinnedOverlay.findIndex(row => row.sessionId === session.sessionId);
244
+ if (overlayIndex !== -1) {
245
+ if (session.pinnedAt === null || session.archivedAt !== null) this.pinnedOverlay.splice(overlayIndex, 1);
246
+ else this.pinnedOverlay[overlayIndex] = session;
247
+ }
239
248
  this.summaries.set(session.sessionId, session);
240
249
  if (this.summaries.size > MAX_INDEX + MAX_DETAILS) {
241
- const victim = [...this.summaries.keys()].find(id => id !== this.book.selectedId && !this.details.has(id) && !this.rows.includes(id));
250
+ const victim = [...this.summaries.keys()].find(id => id !== this.book.selectedId && !this.details.has(id)
251
+ && !this.rows.includes(id) && !this.pinnedOverlay.some(row => row.sessionId === id));
242
252
  if (victim) { this.summaries.delete(victim); this.attention.delete(victim); }
243
253
  }
244
254
  const draft = this.book.sessions.get(session.sessionId);
@@ -376,7 +386,7 @@ export class CodeController {
376
386
  && item.itemId.startsWith(`${session.turnId}:steer:`));
377
387
  const unconfirmed = draft.steer?.state !== 'unknown' ? null : draft.steer.unrecorded ? FOLLOW_UP_UNRECORDED : FOLLOW_UP_UNCONFIRMED;
378
388
  return {
379
- catalog: this.catalog, sessions: this.rows.map(id => {
389
+ catalog: this.catalog, sessions: [...this.rows, ...this.pinnedOverlay.map(row => row.sessionId).filter(id => !this.rows.includes(id))].map(id => {
380
390
  const row = this.info(id), detail = this.details.get(id);
381
391
  return row ? publish(row, detail) : row;
382
392
  }).filter((row): row is CodeSessionInfo => !!row),
@@ -386,7 +396,7 @@ export class CodeController {
386
396
  input: draft.input, selection: session ? sessionSelection(session) : draft.selection,
387
397
  gitInfo: this.gitInfo, loading: this.indexLoading || !!detail?.hydrating || (!!id && !detail?.hydrated && !detail?.error),
388
398
  pending, busy: codeSessionBusy(session), working: !!draft.awaitingTurn || codeSessionBusy(session),
389
- workingIds: new Set(this.rows.filter(rowId => {
399
+ workingIds: new Set([...this.rows, ...this.pinnedOverlay.map(row => row.sessionId)].filter(rowId => {
390
400
  const row = this.info(rowId);
391
401
  return !!this.book.sessions.get(rowId)?.awaitingTurn || (!!row && codeSessionBusy(row));
392
402
  })), followUp, followUpSent, steering: draft.steer?.state === 'sending', synced, transport: this.transport, workspacePicking: this.workspacePicking,
@@ -400,7 +410,7 @@ export class CodeController {
400
410
  newSession: this.newSession, selectSession: this.selectSession, setInput: this.setInput,
401
411
  setSelection: this.setSelection, pickWorkspace: this.pickWorkspace, send: this.send,
402
412
  retrySameSend: this.retrySameSend, stop: this.stop, resume: this.resume, rollbackSession: this.rollbackSession, rename: this.rename,
403
- archive: this.archive, answer: this.answer, refresh: this.refresh, loadMoreSessions: this.loadMoreSessions,
413
+ archive: this.archive, pin: this.pin, markUnread: this.markUnread, answer: this.answer, refresh: this.refresh, loadMoreSessions: this.loadMoreSessions,
404
414
  loadOlderHistory: this.loadOlderHistory, setFilter: this.setFilter, clearError: this.clearError,
405
415
  };
406
416
  }
@@ -434,7 +444,13 @@ export class CodeController {
434
444
  const page = await this.client.listSessions({ ...this.filter, limit,
435
445
  ...(cursor === null ? {} : { cursor }),
436
446
  ...(this.filter.scope === 'cwd' ? { cwd: this.workingDir } : {}) }, this.abort.signal);
447
+ const overlay = more || this.filter.archived ? null : await this.client.listSessions({
448
+ ...(this.filter.scope === 'cwd' ? { cwd: this.workingDir } : {}),
449
+ archived: false, pinned: true, limit: MAX_INDEX,
450
+ }, this.abort.signal).catch(() => null);
437
451
  if (!this.active || life !== this.lifetime || generation !== this.indexGeneration) return;
452
+ if (!more && !overlay) this.pinnedOverlay = [];
453
+ this.rows = [...new Set([...(more ? this.rows : []), ...page.sessions.map(row => row.sessionId)])].slice(0, MAX_INDEX);
438
454
  for (const session of page.sessions) {
439
455
  // A listing is one of the two reads that can see the live runtime.
440
456
  this.observe(session);
@@ -445,12 +461,23 @@ export class CodeController {
445
461
  void this.sync(session.sessionId);
446
462
  }
447
463
  }
448
- this.rows = [...new Set([...(more ? this.rows : []), ...page.sessions.map(row => row.sessionId)])].slice(0, MAX_INDEX);
464
+ if (overlay) {
465
+ const retained: CodeSessionInfo[] = [];
466
+ for (const remote of overlay.sessions) {
467
+ const local = this.info(remote.sessionId);
468
+ const session = local && !newer(remote, local) ? local : remote;
469
+ if (session.pinnedAt === null || session.archivedAt !== null) continue;
470
+ retained.push(session);
471
+ if (session === remote) { this.observe(session); this.accept(session); }
472
+ }
473
+ this.pinnedOverlay = retained;
474
+ }
449
475
  this.cursor = page.nextCursor;
450
476
  this.fetched = (more ? this.fetched : 0) + page.sessions.length;
451
477
  this.moreSessions = page.hasMore && this.rows.length < MAX_INDEX;
452
478
  this.indexError = null;
453
- const retained = new Set([...this.rows, ...this.details.keys(), ...(this.book.selectedId ? [this.book.selectedId] : [])]);
479
+ const retained = new Set([...this.rows, ...this.pinnedOverlay.map(row => row.sessionId),
480
+ ...this.details.keys(), ...(this.book.selectedId ? [this.book.selectedId] : [])]);
454
481
  for (const id of this.summaries.keys()) if (!retained.has(id)) { this.summaries.delete(id); this.attention.delete(id); }
455
482
  } catch (error) {
456
483
  if (this.active && life === this.lifetime && generation === this.indexGeneration) this.indexError = message(error);
@@ -557,7 +584,7 @@ export class CodeController {
557
584
  await Promise.all([this.readCatalog(), this.readIndex(), id ? this.sync(id, true) : Promise.resolve(), this.readGit()]);
558
585
  };
559
586
  loadMoreSessions = async (): Promise<void> => { if (this.moreSessions && !this.indexLoading) await this.readIndex(true); };
560
- setFilter = (filter: CodeSessionFilter): void => { this.filter = { ...filter }; this.cursor = null; this.fetched = 0; this.rows = []; this.moreSessions = false; this.notify(); void this.readIndex(); };
587
+ setFilter = (filter: CodeSessionFilter): void => { this.filter = { ...filter }; this.cursor = null; this.fetched = 0; this.rows = []; this.pinnedOverlay = []; this.moreSessions = false; this.notify(); void this.readIndex(); };
561
588
  // Explicitly abandon only the local uncertain attempt. The server session may exist.
562
589
  startAnotherSession = (): void => {
563
590
  const previous = this.book.fresh;
@@ -704,6 +731,37 @@ export class CodeController {
704
731
  // Other actions report handled failures through the controller model.
705
732
  rename = async (id: string, title: string): Promise<void> => { await this.patch(id, { title }, true); };
706
733
  archive = async (id: string, archived: boolean): Promise<void> => { await this.patch(id, { archived }, true); };
734
+ // Sidebar metadata is allowed while a session is busy, but it still shares
735
+ // the per-session operation slot: two PATCHes would race on the same
736
+ // expectedRevision and one would lose.
737
+ private async sidebarPatch(id: string, input: { pinned?: boolean; unread?: boolean }): Promise<void> {
738
+ const draft = this.draft(id);
739
+ const session = this.info(id);
740
+ if (!session || draft.operation.kind !== 'idle') {
741
+ const error = new Error(!session ? 'Refresh this session before changing it.'
742
+ : 'Wait for the current session action to finish before changing it.');
743
+ draft.operation.error = error.message; this.notify();
744
+ throw error;
745
+ }
746
+ let failure: Error | null = null;
747
+ draft.operation = { kind: 'patching', error: null }; this.notify();
748
+ try {
749
+ this.accept(await this.client.patchSession(id, { ...input, expectedRevision: session.revision }));
750
+ draft.operation = { kind: 'idle', error: null };
751
+ } catch (error) {
752
+ if (error instanceof CodeClientError && error.session?.sessionId === id) {
753
+ // A revision conflict answers with the snapshot session, overlay included.
754
+ this.observe(error.session);
755
+ this.accept(error.session);
756
+ }
757
+ failure = error instanceof CodeClientError ? error : new Error(message(error));
758
+ draft.operation = { kind: 'idle', error: failure.message };
759
+ }
760
+ this.notify(); this.scheduleIndex();
761
+ if (failure) throw failure;
762
+ }
763
+ pin = async (id: string, pinned: boolean): Promise<void> => { await this.sidebarPatch(id, { pinned }); };
764
+ markUnread = async (id: string, unread: boolean): Promise<void> => { await this.sidebarPatch(id, { unread }); };
707
765
  send = async (): Promise<void> => {
708
766
  let id = this.book.selectedId;
709
767
  let draft = this.draft(id);
@@ -64,6 +64,10 @@ export interface CodeControllerModel {
64
64
  rollbackSession(itemId: string): Promise<void>;
65
65
  rename(id: string, title: string): Promise<void>;
66
66
  archive(id: string, archived: boolean): Promise<void>;
67
+ /** Sidebar pin/unpin; allowed while the session is busy. */
68
+ pin(id: string, pinned: boolean): Promise<void>;
69
+ /** Sidebar mark unread/read; allowed while the session is busy. */
70
+ markUnread(id: string, unread: boolean): Promise<void>;
67
71
  answer(permission: CodePermissionRequest, optionId: string): Promise<void>;
68
72
  refresh(): Promise<void>;
69
73
  loadMoreSessions(): Promise<void>;
@@ -29,7 +29,7 @@ export interface CodeGitInfo {
29
29
  }
30
30
 
31
31
  export interface CodeListOptions {
32
- scope?: 'all' | 'cwd'; cwd?: string; archived?: boolean; cursor?: string; limit?: number;
32
+ scope?: 'all' | 'cwd'; cwd?: string; archived?: boolean; pinned?: boolean; cursor?: string; limit?: number;
33
33
  }
34
34
  export interface CodeSessionClient {
35
35
  listSessions(options?: CodeListOptions, signal?: AbortSignal): Promise<CodeSessionPage>;
@@ -36,3 +36,35 @@
36
36
  display: flex; align-items: center; gap: 6px; padding: 8px 12px 12px;
37
37
  color: var(--text-dim); font-size: 12px;
38
38
  }
39
+
40
+ /* Row hover actions: pin + archive replace the status text at the right edge,
41
+ Codex-style. Opacity (not display) keeps them in the tab order. */
42
+ .code-session-item-wrap { position: relative; }
43
+ .code-session-hover-actions {
44
+ position: absolute; top: 50%; right: 6px; transform: translateY(-50%);
45
+ display: inline-flex; align-items: center; gap: 2px;
46
+ opacity: 0; pointer-events: none;
47
+ }
48
+ .code-session-row:hover .code-session-hover-actions,
49
+ .code-session-row:focus-within .code-session-hover-actions { opacity: 1; pointer-events: auto; }
50
+ @media (hover: none) {
51
+ .code-session-hover-actions { opacity: 1; pointer-events: auto; }
52
+ }
53
+ .code-session-hover-btn {
54
+ display: inline-flex; align-items: center; justify-content: center;
55
+ width: 22px; height: 22px; min-height: 22px; padding: 0;
56
+ border: none; border-radius: 5px;
57
+ background: transparent; color: var(--text-dim); cursor: pointer;
58
+ }
59
+ .code-session-hover-btn:hover:not(:disabled) { background: var(--accent-soft); color: var(--accent); }
60
+ .code-session-hover-btn.is-on { color: var(--accent); }
61
+ .code-session-row:hover .code-session-item,
62
+ .code-session-row:focus-within .code-session-item { padding-right: 78px; }
63
+ @media (hover: none) {
64
+ .code-session-item { padding-right: 78px; }
65
+ .code-session-status { visibility: hidden; }
66
+ }
67
+ /* Only the status text yields to the hover icons; a pending-approval marker
68
+ stays on screen — hovering to pin must not hide the thing asking for input. */
69
+ .code-session-row:hover .code-session-status,
70
+ .code-session-row:focus-within .code-session-status { visibility: hidden; }
@@ -775,9 +775,7 @@
775
775
  white-space: nowrap;
776
776
  }
777
777
  .code-session-row { margin-bottom: 6px; }
778
- .code-session-actions { font-size: 11px; color: var(--text-secondary); margin: 0 8px; }
779
- .code-session-actions summary { cursor: pointer; min-height: 24px; }
780
- .code-session-actions > div, .code-session-rename { display: flex; flex-wrap: wrap; gap: 6px; padding: 4px 0; }
778
+ .code-session-rename { display: flex; flex-wrap: wrap; gap: 6px; padding: 4px 0; }
781
779
  .code-session-rename input { min-width: 0; width: 100%; padding: 5px; border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); background: var(--bg-base); color: var(--text-primary); font: inherit; }
782
780
  .code-session-archive-filter { display: flex; align-items: center; gap: 5px; margin: 4px 8px 8px; color: var(--text-secondary); font-size: 11px; }
783
781
  .code-session-search { box-sizing: border-box; }
@@ -792,7 +790,7 @@
792
790
  .code-message-text { overflow-wrap: anywhere; }
793
791
  .code-transcript-controls { display: flex; flex-wrap: wrap; gap: 8px; padding: 4px 16px; }
794
792
  .code-transcript-controls:empty { display: none; }
795
- .code-transcript-controls button, .code-inline-action, .code-session-actions button,
793
+ .code-transcript-controls button, .code-inline-action,
796
794
  .code-session-rename button, .code-input-recovery button, .code-transport-status button,
797
795
  .code-recovery-strip button { min-height: 26px; padding: 3px 8px; border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); background: var(--bg-panel); color: var(--text-primary); font: inherit; font-size: 11px; cursor: pointer; }
798
796
  .code-canvas button:disabled, .code-session-list button:disabled { opacity: .6; cursor: not-allowed; }
@@ -24,6 +24,14 @@ export function compareCodeSessions(left: CodeSessionInfo, right: CodeSessionInf
24
24
  return right.createdAt - left.createdAt || left.sessionId.localeCompare(right.sessionId);
25
25
  }
26
26
 
27
+ /**
28
+ * Pinned rows leave their workspace or activity group and sit together on top,
29
+ * most recently pinned first. `null` sorts below any real pin.
30
+ */
31
+ export function comparePinnedCodeSessions(left: CodeSessionInfo, right: CodeSessionInfo): number {
32
+ return (right.pinnedAt ?? 0) - (left.pinnedAt ?? 0) || compareCodeSessions(left, right);
33
+ }
34
+
27
35
  export type CodeSessionGroup = {
28
36
  section: CodeSessionSection;
29
37
  sessions: CodeSessionInfo[];
@@ -77,6 +85,7 @@ export function codeSessionAttentionLabel(attention: CodeSessionAttention): stri
77
85
  */
78
86
  export function codeSessionUnread(session: CodeSessionInfo): boolean {
79
87
  if (session.archivedAt !== null) return false;
88
+ if (session.markedUnread) return true;
80
89
  const completed = session.lastTurnCompletedAt;
81
90
  const visited = session.lastVisitedAt;
82
91
  if (completed === null || visited === null) return false;
@@ -12,7 +12,6 @@ type CommandBarProps = {
12
12
  onRefresh: () => void;
13
13
  onOpenDrawer: () => void;
14
14
  onThemeChange: (next: DashboardUiTheme) => void;
15
- onOpenPalette: () => void;
16
15
  };
17
16
 
18
17
  export function CommandBar(props: CommandBarProps) {
@@ -47,15 +46,6 @@ export function CommandBar(props: CommandBarProps) {
47
46
  <div className="command-actions-group">
48
47
  <DesktopPanelControls />
49
48
  <DesktopLink />
50
- <button
51
- type="button"
52
- className="command-palette-trigger"
53
- onClick={props.onOpenPalette}
54
- aria-label="Open command palette"
55
- title="Open command palette (⌘K / Ctrl+K)"
56
- >
57
- <span aria-hidden="true">⌘K</span>
58
- </button>
59
49
  <ThemeSwitch theme={props.theme} onChange={props.onThemeChange} />
60
50
  <button
61
51
  type="button"
@@ -43,6 +43,7 @@ type InstanceGroupsProps = {
43
43
  onPreview: (instance: DashboardInstance) => void;
44
44
  onMarkActivitySeen: (port: number) => void;
45
45
  onInstanceLabelSave: (port: number, label: string | null) => Promise<void>;
46
+ onToggleFavorite?: (instance: DashboardInstance) => void;
46
47
  onLifecycle: (action: DashboardLifecycleAction, instance: DashboardInstance) => void;
47
48
  };
48
49
 
@@ -139,6 +140,7 @@ function renderInstanceRow(
139
140
  onPreview={props.onPreview}
140
141
  onMarkActivitySeen={props.onMarkActivitySeen}
141
142
  onInstanceLabelSave={props.onInstanceLabelSave}
143
+ {...(props.onToggleFavorite !== undefined ? { onToggleFavorite: props.onToggleFavorite } : {})}
142
144
  onLifecycle={props.onLifecycle}
143
145
  jumpHint={jumpHint ?? null}
144
146
  />