aidevops 3.27.0 → 3.29.0

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,6 +1,6 @@
1
1
  export type GuiRouteClassification = "read" | "write" | "destructive";
2
2
 
3
- export type GuiOperationId = "setup.status.read" | "capabilities.read" | "filesystem.read" | "vault.status.read";
3
+ export type GuiOperationId = "setup.status.read" | "capabilities.read" | "filesystem.read" | "vault.status.read" | "apps.action.run" | "apps.action.status";
4
4
 
5
5
  export type GuiFileRootId = "agents" | "config" | "localSetup" | "git";
6
6
 
@@ -23,7 +23,7 @@ export interface GuiResponseEnvelope<TData> {
23
23
 
24
24
  export interface GuiRouteManifest {
25
25
  route: string;
26
- method: "GET";
26
+ method: "GET" | "POST";
27
27
  operation_id: GuiOperationId;
28
28
  classification: GuiRouteClassification;
29
29
  source_surface: string;
@@ -68,6 +68,44 @@ export interface GuiFileExplorerData {
68
68
  entry_limit: number;
69
69
  }
70
70
 
71
+ export type GuiAppActionId = "install" | "update" | "reinstall" | "remove";
72
+
73
+ export interface GuiManagedAppActionSummary {
74
+ id: GuiAppActionId;
75
+ label: string;
76
+ enabled: boolean;
77
+ command_preview: string;
78
+ confirmation: "none" | "recommended" | "required";
79
+ }
80
+
81
+ export interface GuiManagedAppSummary {
82
+ id: string;
83
+ name: string;
84
+ description: string;
85
+ category: string;
86
+ origin_website_url: string;
87
+ origin_repo_url: string;
88
+ aidevops_install: boolean;
89
+ aidevops_update: boolean;
90
+ installed_version: string;
91
+ latest_version: string;
92
+ install_path_ref: string;
93
+ status: "found" | "missing" | "unchecked";
94
+ actions: GuiManagedAppActionSummary[];
95
+ }
96
+
97
+ export interface GuiAppActionJobSummary {
98
+ id: string;
99
+ app_id: string;
100
+ action: GuiAppActionId;
101
+ status: "running" | "completed" | "failed" | "rejected";
102
+ command_preview: string;
103
+ started_at: string;
104
+ finished_at: string | null;
105
+ exit_code: number | null;
106
+ output: string[];
107
+ }
108
+
71
109
  export interface GuiSecretReference {
72
110
  name: string;
73
111
  status: "configured" | "missing" | "unchecked";
@@ -318,6 +356,7 @@ export interface GuiStatusData {
318
356
  oauth_pool: GuiOAuthPoolSummary;
319
357
  setup_targets: GuiSetupTargetSummary[];
320
358
  ai_apps: GuiAiAppSummary[];
359
+ managed_apps: GuiManagedAppSummary[];
321
360
  vault: GuiVaultStatusData;
322
361
  capabilities: GuiCapabilitySummary[];
323
362
  secrets: GuiSecretReference[];
@@ -357,6 +396,28 @@ export const VAULT_STATUS_ROUTE_MANIFEST: GuiRouteManifest = {
357
396
  redactions: ["secret_values", "credential_paths", "private_key_material", "vault_passphrases", "recovery_material"],
358
397
  };
359
398
 
399
+ export const APP_ACTION_ROUTE_MANIFEST: GuiRouteManifest = {
400
+ route: "/api/apps/:appId/actions/:action",
401
+ method: "POST",
402
+ operation_id: "apps.action.run",
403
+ classification: "write",
404
+ source_surface: "apps",
405
+ adapter: "appActions.startAppAction",
406
+ command_pattern: ["allowlisted", "aidevops", "setup/update", "background-job"],
407
+ redactions: ["secret_values", "credential_paths", "private_key_material"],
408
+ };
409
+
410
+ export const APP_ACTION_STATUS_ROUTE_MANIFEST: GuiRouteManifest = {
411
+ route: "/api/apps/jobs/:jobId",
412
+ method: "GET",
413
+ operation_id: "apps.action.status",
414
+ classification: "read",
415
+ source_surface: "apps",
416
+ adapter: "appActions.readAppActionJob",
417
+ command_pattern: ["background-job", "metadata-and-output-only"],
418
+ redactions: ["secret_values", "credential_paths", "private_key_material"],
419
+ };
420
+
360
421
  export const GUI_FILE_ROOTS: readonly GuiFileRootDefinition[] = [
361
422
  {
362
423
  id: "agents",
@@ -162,6 +162,7 @@ export const statusFixture: GuiStatusData = {
162
162
  needs_update: false,
163
163
  },
164
164
  ],
165
+ managed_apps: [],
165
166
  vault: {
166
167
  status: "uninitialized",
167
168
  setup_state: "uninitialized",
@@ -1,5 +1,5 @@
1
1
  import type { GuiResponseEnvelope, GuiStatusData } from "@aidevops/gui-shared";
2
- import { type ReactElement, useEffect, useState } from "react";
2
+ import { type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactElement, useEffect, useState } from "react";
3
3
  import { MachineRail, Sidebar } from "./AppNavigation";
4
4
  import { Workspace } from "./AppWorkspace";
5
5
  import type { ConversationMode, FontPreference, FontSizePreference, ShellMode, SurfaceId, ThemePreference } from "./app-model";
@@ -32,9 +32,14 @@ export const appearanceStorageKeys = {
32
32
  machineRail: "aidevops-gui-show-machine-rail",
33
33
  showBorders: "aidevops-gui-show-borders",
34
34
  showNavCounts: "aidevops-gui-show-nav-counts",
35
+ sidebarWidth: "aidevops-gui-sidebar-width",
35
36
  theme: "aidevops-gui-theme",
36
37
  } as const;
37
38
 
39
+ const defaultSidebarWidth = 302;
40
+ const minSidebarWidth = 248;
41
+ const maxSidebarWidth = 520;
42
+
38
43
  export interface StoredAppearancePreferences {
39
44
  accentHue: number;
40
45
  fontPreference: FontPreference;
@@ -76,6 +81,7 @@ export function App(): ReactElement {
76
81
  const [shellMode, setShellMode] = useState<ShellMode>("devices");
77
82
  const [conversationMode, setConversationMode] = useState<ConversationMode>("ai");
78
83
  const [selectedLocalRepoIndex, setSelectedLocalRepoIndex] = useState(0);
84
+ const [selectedSessionId, setSelectedSessionId] = useState<string | undefined>();
79
85
  const [systemTheme, setSystemTheme] = useState<"light" | "dark">("light");
80
86
  const resolvedTheme = themePreference === "system" ? systemTheme : themePreference;
81
87
  const activeSurface: SurfaceId = navigation.entries[navigation.index] ?? "overview";
@@ -184,12 +190,14 @@ export function App(): ReactElement {
184
190
  fontSizePreference={fontSizePreference}
185
191
  fontPreference={fontPreference}
186
192
  selectedLocalRepoIndex={selectedLocalRepoIndex}
193
+ selectedSessionId={selectedSessionId}
187
194
  setAccentHue={setAccentHue}
188
195
  setActiveSurface={setActiveSurface}
189
196
  setConversationMode={setConversationMode}
190
197
  setFontSizePreference={setFontSizePreference}
191
198
  setFontPreference={setFontPreference}
192
199
  setSelectedLocalRepoIndex={setSelectedLocalRepoIndex}
200
+ setSelectedSessionId={setSelectedSessionId}
193
201
  setShellMode={setShellMode}
194
202
  setShowBorders={setShowBorders}
195
203
  setShowNavCounts={setShowNavCounts}
@@ -200,6 +208,7 @@ export function App(): ReactElement {
200
208
  status={status.data}
201
209
  themePreference={themePreference}
202
210
  />
211
+ <SidebarResizeHandle />
203
212
  <Workspace
204
213
  activeItem={activeItem}
205
214
  activeSectionLabel={activeSectionLabel}
@@ -211,7 +220,12 @@ export function App(): ReactElement {
211
220
  goBack={goBack}
212
221
  goForward={goForward}
213
222
  selectedLocalRepoIndex={selectedLocalRepoIndex}
223
+ selectedSessionId={selectedSessionId}
214
224
  setActiveSurface={setActiveSurface}
225
+ setConversationMode={setConversationMode}
226
+ setSelectedLocalRepoIndex={setSelectedLocalRepoIndex}
227
+ setSelectedSessionId={setSelectedSessionId}
228
+ setShellMode={setShellMode}
215
229
  shellMode={shellMode}
216
230
  status={status.data}
217
231
  />
@@ -220,6 +234,54 @@ export function App(): ReactElement {
220
234
  );
221
235
  }
222
236
 
237
+ export function clampSidebarWidth(width: number): number {
238
+ return Math.min(maxSidebarWidth, Math.max(minSidebarWidth, Math.round(width)));
239
+ }
240
+
241
+ function SidebarResizeHandle(): ReactElement {
242
+ const [sidebarWidth, setSidebarWidth] = useState(() => readStoredSidebarWidth());
243
+
244
+ useEffect(() => {
245
+ document.documentElement.style.setProperty("--sidebar-width", `${sidebarWidth}px`);
246
+ persistAppearancePreference(appearanceStorageKeys.sidebarWidth, String(sidebarWidth));
247
+ }, [sidebarWidth]);
248
+
249
+ const startSidebarResize = (event: ReactMouseEvent<HTMLElement>) => {
250
+ const startX = event.clientX;
251
+ const startWidth = sidebarWidth;
252
+ const resizeSidebar = (moveEvent: MouseEvent) => setSidebarWidth(clampSidebarWidth(startWidth + moveEvent.clientX - startX));
253
+ const stopResize = () => {
254
+ document.documentElement.classList.remove("resizing-sidebar");
255
+ window.removeEventListener("mousemove", resizeSidebar);
256
+ window.removeEventListener("mouseup", stopResize);
257
+ };
258
+
259
+ event.preventDefault();
260
+ document.documentElement.classList.add("resizing-sidebar");
261
+ window.addEventListener("mousemove", resizeSidebar);
262
+ window.addEventListener("mouseup", stopResize);
263
+ };
264
+
265
+ const resizeSidebarWithKeyboard = (event: ReactKeyboardEvent<HTMLElement>) => {
266
+ if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
267
+ event.preventDefault();
268
+ setSidebarWidth((current) => clampSidebarWidth(current + (event.key === "ArrowRight" ? 16 : -16)));
269
+ }
270
+
271
+ if (event.key === "Home" || event.key === "End") {
272
+ event.preventDefault();
273
+ setSidebarWidth(event.key === "Home" ? minSidebarWidth : maxSidebarWidth);
274
+ }
275
+ };
276
+
277
+ return <hr aria-label="Resize sidebar" aria-orientation="vertical" aria-valuemax={maxSidebarWidth} aria-valuemin={minSidebarWidth} aria-valuenow={sidebarWidth} className="sidebar-resize-handle" onKeyDown={resizeSidebarWithKeyboard} onMouseDown={startSidebarResize} tabIndex={0} />;
278
+ }
279
+
280
+ function readStoredSidebarWidth(storage: ReadableAppearanceStorage | undefined = browserLocalStorage()): number {
281
+ const savedWidth = Number.parseInt(storage?.getItem(appearanceStorageKeys.sidebarWidth) ?? "", 10);
282
+ return Number.isFinite(savedWidth) ? clampSidebarWidth(savedWidth) : defaultSidebarWidth;
283
+ }
284
+
223
285
  function sendNativeAccentHue(hue: number): void {
224
286
  const webkit = (window as WebKitBridgeWindow).webkit;
225
287
  webkit?.messageHandlers?.accentHue?.postMessage(hue);
@@ -20,6 +20,7 @@ import {
20
20
  FiGrid,
21
21
  FiHash,
22
22
  FiHardDrive,
23
+ FiHelpCircle,
23
24
  FiLink,
24
25
  FiLink2,
25
26
  FiList,
@@ -54,6 +55,8 @@ const surfaceIcons: Record<SurfaceIconName, IconType> = {
54
55
  globe: FiGlobe,
55
56
  grid: FiGrid,
56
57
  hardDrive: FiHardDrive,
58
+ hash: FiHash,
59
+ help: FiHelpCircle,
57
60
  link: FiLink,
58
61
  list: FiList,
59
62
  lock: FiLock,
@@ -107,19 +110,21 @@ export function MachineRail({ machine }: { machine?: GuiMachineSummary }) {
107
110
  );
108
111
  }
109
112
 
110
- export function Sidebar({ activeSurface, accentHue, conversationMode, fontPreference, fontSizePreference, selectedLocalRepoIndex, setAccentHue, setActiveSurface, setConversationMode, setFontPreference, setFontSizePreference, setSelectedLocalRepoIndex, setShellMode, setShowBorders, setShowNavCounts, setThemePreference, shellMode, showBorders, showNavCounts, status, themePreference }: {
113
+ export function Sidebar({ activeSurface, accentHue, conversationMode, fontPreference, fontSizePreference, selectedLocalRepoIndex, selectedSessionId, setAccentHue, setActiveSurface, setConversationMode, setFontPreference, setFontSizePreference, setSelectedLocalRepoIndex, setSelectedSessionId, setShellMode, setShowBorders, setShowNavCounts, setThemePreference, shellMode, showBorders, showNavCounts, status, themePreference }: {
111
114
  activeSurface: SurfaceId;
112
115
  accentHue: number;
113
116
  conversationMode: ConversationMode;
114
117
  fontPreference: FontPreference;
115
118
  fontSizePreference: FontSizePreference;
116
119
  selectedLocalRepoIndex: number;
120
+ selectedSessionId: string | undefined;
117
121
  setAccentHue: (hue: number) => void;
118
122
  setActiveSurface: (surface: SurfaceId) => void;
119
123
  setConversationMode: (mode: ConversationMode) => void;
120
124
  setFontPreference: (font: FontPreference) => void;
121
125
  setFontSizePreference: (size: FontSizePreference) => void;
122
126
  setSelectedLocalRepoIndex: (index: number) => void;
127
+ setSelectedSessionId: (id: string | undefined) => void;
123
128
  setShellMode: (mode: ShellMode) => void;
124
129
  setShowBorders: (show: boolean) => void;
125
130
  setShowNavCounts: (show: boolean) => void;
@@ -151,8 +156,10 @@ export function Sidebar({ activeSurface, accentHue, conversationMode, fontPrefer
151
156
  </> : <ConversationSidebar
152
157
  conversationMode={conversationMode}
153
158
  selectedLocalRepoIndex={selectedLocalRepoIndex}
159
+ selectedSessionId={selectedSessionId}
154
160
  setConversationMode={setConversationMode}
155
161
  setSelectedLocalRepoIndex={setSelectedLocalRepoIndex}
162
+ setSelectedSessionId={setSelectedSessionId}
156
163
  status={status}
157
164
  />}
158
165
  </nav>
@@ -200,11 +207,13 @@ function IconSwitch<TValue extends string>({ ariaLabel, options, value }: {
200
207
  );
201
208
  }
202
209
 
203
- function ConversationSidebar({ conversationMode, selectedLocalRepoIndex, setConversationMode, setSelectedLocalRepoIndex, status }: {
210
+ function ConversationSidebar({ conversationMode, selectedLocalRepoIndex, selectedSessionId, setConversationMode, setSelectedLocalRepoIndex, setSelectedSessionId, status }: {
204
211
  conversationMode: ConversationMode;
205
212
  selectedLocalRepoIndex: number;
213
+ selectedSessionId: string | undefined;
206
214
  setConversationMode: (mode: ConversationMode) => void;
207
215
  setSelectedLocalRepoIndex: (index: number) => void;
216
+ setSelectedSessionId: (id: string | undefined) => void;
208
217
  status: GuiStatusData;
209
218
  }) {
210
219
  const repos = status.local_repos.repos;
@@ -212,6 +221,7 @@ function ConversationSidebar({ conversationMode, selectedLocalRepoIndex, setConv
212
221
  const sessions = selectedRepo === undefined
213
222
  ? []
214
223
  : status.opencode_sessions.sessions.filter((session) => session.repo_path_ref === selectedRepo.path_ref);
224
+ const activeSessionId = sessions.some((session) => session.id_ref === selectedSessionId) ? selectedSessionId : sessions[0]?.id_ref;
215
225
  const canSelectPreviousRepo = selectedLocalRepoIndex > 0;
216
226
  const canSelectNextRepo = selectedLocalRepoIndex < repos.length - 1;
217
227
 
@@ -223,19 +233,18 @@ function ConversationSidebar({ conversationMode, selectedLocalRepoIndex, setConv
223
233
  <section className="repo-session-selector" aria-label={text.localRepoSelector}>
224
234
  <span className="repo-selector-heading" id="local-repo-selector-label">{text.localRepos}</span>
225
235
  <div className="selector-with-stepper repo-selector-row">
226
- <button aria-label="Previous local repo" className="selector-step-button" disabled={!canSelectPreviousRepo} onClick={() => setSelectedLocalRepoIndex(Math.max(0, selectedLocalRepoIndex - 1))} type="button"><FiChevronLeft aria-hidden="true" /></button>
227
- <select aria-labelledby="local-repo-selector-label" onChange={(event) => setSelectedLocalRepoIndex(Number.parseInt(event.currentTarget.value, 10))} value={Math.min(selectedLocalRepoIndex, Math.max(0, repos.length - 1))}>
236
+ <button aria-label="Previous local repo" className="selector-step-button" disabled={!canSelectPreviousRepo} onClick={() => { setSelectedLocalRepoIndex(Math.max(0, selectedLocalRepoIndex - 1)); setSelectedSessionId(undefined); }} type="button"><FiChevronLeft aria-hidden="true" /></button>
237
+ <select aria-labelledby="local-repo-selector-label" onChange={(event) => { setSelectedLocalRepoIndex(Number.parseInt(event.currentTarget.value, 10)); setSelectedSessionId(undefined); }} value={Math.min(selectedLocalRepoIndex, Math.max(0, repos.length - 1))}>
228
238
  {repos.length === 0 ? <option value={0}>No local repos</option> : repos.map((repo, index) => <option key={repo.path_ref} value={index}>{repo.name}</option>)}
229
239
  </select>
230
- <button aria-label="Next local repo" className="selector-step-button" disabled={!canSelectNextRepo} onClick={() => setSelectedLocalRepoIndex(Math.min(repos.length - 1, selectedLocalRepoIndex + 1))} type="button"><FiChevronRight aria-hidden="true" /></button>
240
+ <button aria-label="Next local repo" className="selector-step-button" disabled={!canSelectNextRepo} onClick={() => { setSelectedLocalRepoIndex(Math.min(repos.length - 1, selectedLocalRepoIndex + 1)); setSelectedSessionId(undefined); }} type="button"><FiChevronRight aria-hidden="true" /></button>
231
241
  </div>
232
242
  </section>
233
243
  <section className="sidebar-group session-history-list">
234
- <h2>{text.sessionHistory}</h2>
235
244
  {selectedRepo ? <ul>
236
245
  {sessions.length === 0
237
246
  ? <li><button className="surface-link active" type="button"><span className="surface-icon" aria-hidden="true"><FiHash /></span><span className="surface-copy"><strong>{selectedRepo.name}</strong><small>No OpenCode sessions found for this repo yet.</small></span><em>{text.planned}</em></button></li>
238
- : sessions.map((session, index) => <li key={session.id_ref}><button className={index === 0 ? "surface-link active" : "surface-link"} type="button"><span className="surface-icon" aria-hidden="true"><FiHash /></span><span className="surface-copy"><strong>{session.title}</strong><small>{session.updated_at}</small></span></button></li>)}
247
+ : sessions.map((session) => <li key={session.id_ref}><button className={activeSessionId === session.id_ref ? "surface-link active" : "surface-link"} onClick={() => setSelectedSessionId(session.id_ref)} type="button"><span className="surface-icon" aria-hidden="true"><FiHash /></span><span className="surface-copy"><strong>{session.title}</strong><small>{session.updated_at}</small></span></button></li>)}
239
248
  </ul> : <p className="empty-sidebar-state">No local repos discovered.</p>}
240
249
  </section>
241
250
  </>}
@@ -248,13 +257,11 @@ function PeopleChannelList() {
248
257
  <section aria-label="People channels">
249
258
  <div className="notice compact-notice">{text.simplexReady}</div>
250
259
  <section className="sidebar-group session-history-list">
251
- <h2>{text.teams}</h2>
252
260
  <ul>
253
261
  {["aidevops", "clients", "ops"].map((team) => <li key={team}><button className="surface-link" type="button"><span className="surface-icon" aria-hidden="true"><FiHash /></span><span className="surface-copy"><strong>{team}</strong><small>SimpleX team channel placeholder</small></span></button></li>)}
254
262
  </ul>
255
263
  </section>
256
264
  <section className="sidebar-group session-history-list">
257
- <h2>{text.directMessages}</h2>
258
265
  <ul>
259
266
  {["Marcus", "AI DevOps"].map((person) => <li key={person}><button className="surface-link" type="button"><span className="surface-icon" aria-hidden="true"><FiMessageSquare /></span><span className="surface-copy"><strong>{person}</strong><small>encrypted DM placeholder</small></span></button></li>)}
260
267
  </ul>
@@ -1,11 +1,11 @@
1
1
  /* jshint esversion: 11 */
2
2
  import { type ReactElement, type ReactNode, useEffect, useState } from "react";
3
- import { FiBell, FiChevronLeft, FiChevronRight, FiCommand, FiCpu, FiGlobe, FiHash, FiLogOut, FiMessageSquare, FiSearch, FiSettings, FiShield, FiUser } from "react-icons/fi";
3
+ import { FiBell, FiChevronLeft, FiChevronRight, FiCommand, FiGlobe, FiHash, FiHelpCircle, FiLogOut, FiMessageSquare, FiSearch, FiSettings, FiShield, FiTerminal, FiUser } from "react-icons/fi";
4
4
  import type { GuiFileRootId, GuiStatusData } from "../../gui-shared/src";
5
5
  import { SurfaceGlyph } from "./AppNavigation";
6
6
  import type { ConversationMode, ShellMode, SurfaceId, SurfaceNavItem } from "./app-model";
7
7
  import { inventorySurfaceConfigs, text } from "./app-model";
8
- import { CommandPalette, commandPaletteShortcutQuery } from "./CommandPalette";
8
+ import { CommandPalette, type CommandPaletteSelection, commandPaletteShortcutQuery } from "./CommandPalette";
9
9
  import { FileExplorerSurface } from "./FileExplorerSurface";
10
10
  import { AppsSurface, EditableInventorySurface, InstallationSurface } from "./InventorySurfaces";
11
11
  import { AiProvidersSurface, LocalReposSurface, LockedVaultGate, OverviewSurface, PlannedSurface, ProjectsSurface, SecuritySurface, VaultSurface } from "./StatusSurfaces";
@@ -16,7 +16,7 @@ const communityLinks = {
16
16
  x: "https://x.com/marcuswquinn",
17
17
  } as const;
18
18
 
19
- export function Workspace({ activeItem, activeSectionLabel, activeSurface, canGoBack, canGoForward, conversationMode, fileRoot, goBack, goForward, selectedLocalRepoIndex, setActiveSurface, shellMode, status }: {
19
+ export function Workspace({ activeItem, activeSectionLabel, activeSurface, canGoBack, canGoForward, conversationMode, fileRoot, goBack, goForward, selectedLocalRepoIndex, selectedSessionId, setActiveSurface, setConversationMode, setSelectedLocalRepoIndex, setSelectedSessionId, setShellMode, shellMode, status }: {
20
20
  activeItem: SurfaceNavItem;
21
21
  activeSectionLabel: string;
22
22
  activeSurface: SurfaceId;
@@ -27,23 +27,28 @@ export function Workspace({ activeItem, activeSectionLabel, activeSurface, canGo
27
27
  goBack: () => void;
28
28
  goForward: () => void;
29
29
  selectedLocalRepoIndex: number;
30
+ selectedSessionId: string | undefined;
30
31
  setActiveSurface: (surface: SurfaceId) => void;
32
+ setConversationMode: (mode: ConversationMode) => void;
33
+ setSelectedLocalRepoIndex: (index: number) => void;
34
+ setSelectedSessionId: (id: string | undefined) => void;
35
+ setShellMode: (mode: ShellMode) => void;
31
36
  shellMode: ShellMode;
32
37
  status: GuiStatusData;
33
38
  }) {
34
39
  return (
35
40
  <section className="app-inset" aria-label={text.workspaceLabel}>
36
- <WorkspaceHeader activeItem={activeItem} activeSectionLabel={activeSectionLabel} activeSurface={activeSurface} canGoBack={canGoBack} canGoForward={canGoForward} goBack={goBack} goForward={goForward} setActiveSurface={setActiveSurface} status={status} />
41
+ <WorkspaceHeader activeItem={activeItem} activeSectionLabel={activeSectionLabel} activeSurface={activeSurface} canGoBack={canGoBack} canGoForward={canGoForward} goBack={goBack} goForward={goForward} setActiveSurface={setActiveSurface} setConversationMode={setConversationMode} setSelectedLocalRepoIndex={setSelectedLocalRepoIndex} setSelectedSessionId={setSelectedSessionId} setShellMode={setShellMode} status={status} />
37
42
  <div className="workspace-scroll">
38
43
  {shellMode === "sessions"
39
- ? <ConversationWorkspace conversationMode={conversationMode} selectedLocalRepoIndex={selectedLocalRepoIndex} status={status} />
44
+ ? <ConversationWorkspace conversationMode={conversationMode} selectedLocalRepoIndex={selectedLocalRepoIndex} selectedSessionId={selectedSessionId} status={status} />
40
45
  : <SurfaceContent activeItem={activeItem} activeSurface={activeSurface} fileRoot={fileRoot} status={status} />}
41
46
  </div>
42
47
  </section>
43
48
  );
44
49
  }
45
50
 
46
- function WorkspaceHeader({ activeItem, activeSectionLabel, activeSurface, canGoBack, canGoForward, goBack, goForward, setActiveSurface, status }: {
51
+ function WorkspaceHeader({ activeItem, activeSectionLabel, activeSurface, canGoBack, canGoForward, goBack, goForward, setActiveSurface, setConversationMode, setSelectedLocalRepoIndex, setSelectedSessionId, setShellMode, status }: {
47
52
  activeItem: SurfaceNavItem;
48
53
  activeSectionLabel: string;
49
54
  activeSurface: SurfaceId;
@@ -52,6 +57,10 @@ function WorkspaceHeader({ activeItem, activeSectionLabel, activeSurface, canGoB
52
57
  goBack: () => void;
53
58
  goForward: () => void;
54
59
  setActiveSurface: (surface: SurfaceId) => void;
60
+ setConversationMode: (mode: ConversationMode) => void;
61
+ setSelectedLocalRepoIndex: (index: number) => void;
62
+ setSelectedSessionId: (id: string | undefined) => void;
63
+ setShellMode: (mode: ShellMode) => void;
55
64
  status: GuiStatusData;
56
65
  }): ReactElement {
57
66
  const [assistantOpen, setAssistantOpen] = useState(false);
@@ -64,31 +73,35 @@ function WorkspaceHeader({ activeItem, activeSectionLabel, activeSurface, canGoB
64
73
 
65
74
  useEffect(() => {
66
75
  const openCommandPalette = (event: KeyboardEvent) => {
67
- if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
68
- event.preventDefault();
69
- setCommandInitialQuery("");
70
- setCommandOpen(true);
71
- return;
72
- }
73
-
74
- const shortcutQuery = commandPaletteShortcutQuery(event);
75
- if (shortcutQuery !== undefined && !isEditableShortcutTarget(event.target)) {
76
- event.preventDefault();
77
- setCommandInitialQuery(shortcutQuery);
78
- setCommandOpen(true);
79
- }
76
+ handleWorkspaceShortcut(event, {
77
+ goBack,
78
+ goForward,
79
+ openCommand: (query) => {
80
+ setCommandInitialQuery(query);
81
+ setCommandOpen(true);
82
+ },
83
+ });
80
84
  };
81
85
 
82
86
  window.addEventListener("keydown", openCommandPalette);
83
87
  return () => window.removeEventListener("keydown", openCommandPalette);
84
- }, []);
88
+ }, [goBack, goForward]);
85
89
 
86
90
  const closeMenus = () => {
87
91
  setNotificationsOpen(false);
88
92
  setProfileOpen(false);
89
93
  };
90
94
 
91
- const openSurface = (surface: SurfaceId) => {
95
+ const openItem = ({ conversationMode, repoPathRef, sessionId, shellMode, surface }: CommandPaletteSelection) => {
96
+ const repoIndex = status.local_repos.repos.findIndex((repo) => repo.path_ref === repoPathRef);
97
+ setShellMode(shellMode ?? "devices");
98
+ if (repoIndex >= 0) {
99
+ setSelectedLocalRepoIndex(repoIndex);
100
+ }
101
+ setSelectedSessionId(sessionId);
102
+ if (conversationMode) {
103
+ setConversationMode(conversationMode);
104
+ }
92
105
  setActiveSurface(surface);
93
106
  closeMenus();
94
107
  setCommandOpen(false);
@@ -116,28 +129,45 @@ function WorkspaceHeader({ activeItem, activeSectionLabel, activeSurface, canGoB
116
129
  </div>
117
130
  <div className="header-actions">
118
131
  <div className="header-action-menu">
132
+ <button aria-label="Open help" className="header-icon-button" onClick={() => openItem({ surface: "help" })} title="Help (?)" type="button">
133
+ <FiHelpCircle aria-hidden="true" />
134
+ </button>
119
135
  <button aria-expanded={notificationsOpen} aria-label="Open notifications" className="header-icon-button" onClick={() => { setNotificationsOpen((current) => !current); setProfileOpen(false); }} type="button">
120
136
  <FiBell aria-hidden="true" />
121
137
  <span className="notification-dot" aria-hidden="true" />
122
138
  </button>
123
- {notificationsOpen ? <NotificationsMenu openSurface={openSurface} /> : null}
139
+ {notificationsOpen ? <NotificationsMenu openSurface={(surface) => openItem({ surface })} /> : null}
124
140
  </div>
125
- <button aria-pressed={assistantOpen} aria-label="Toggle AI Assistant" className={assistantOpen ? "header-icon-button active" : "header-icon-button"} onClick={() => setAssistantOpen((current) => !current)} type="button">
126
- <FiCpu aria-hidden="true" />
141
+ <button aria-pressed={assistantOpen} aria-label="Toggle AI Assistant" className={assistantOpen ? "header-icon-button active" : "header-icon-button"} onClick={() => setAssistantOpen((current) => !current)} title="AI sessions (_)" type="button">
142
+ <FiTerminal aria-hidden="true" />
127
143
  </button>
128
144
  <div className="header-action-menu">
129
145
  <button aria-expanded={profileOpen} aria-label={`Open profile menu for ${userName}`} className="profile-avatar-button" onClick={() => { setProfileOpen((current) => !current); setNotificationsOpen(false); }} title={userName} type="button">
130
146
  <span>{userInitials}</span>
131
147
  </button>
132
- {profileOpen ? <ProfileMenu openSurface={openSurface} userName={userName} /> : null}
148
+ {profileOpen ? <ProfileMenu openSurface={(surface) => openItem({ surface })} userName={userName} /> : null}
133
149
  </div>
134
150
  </div>
135
151
  {assistantOpen ? <AssistantPanel activeSurface={activeSurface} userName={userName} /> : null}
136
- {commandOpen ? <CommandPalette close={() => setCommandOpen(false)} initialQuery={commandInitialQuery} openSurface={openSurface} status={status} /> : null}
152
+ {commandOpen ? <CommandPalette activeSurface={activeSurface} close={() => setCommandOpen(false)} initialQuery={commandInitialQuery} openItem={openItem} status={status} /> : null}
137
153
  </header>
138
154
  );
139
155
  }
140
156
 
157
+ function handleWorkspaceShortcut(event: KeyboardEvent, actions: { goBack: () => void; goForward: () => void; openCommand: (query: string) => void }): void {
158
+ const shortcutQuery = commandPaletteShortcutQuery(event);
159
+ const isEditableTarget = isEditableShortcutTarget(event.target);
160
+ const commandQuery = (event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k" ? "" : shortcutQuery;
161
+
162
+ if (commandQuery !== undefined && !isEditableTarget) {
163
+ event.preventDefault();
164
+ actions.openCommand(commandQuery);
165
+ } else if (event.key === "Backspace" && !isEditableTarget) {
166
+ event.preventDefault();
167
+ (event.shiftKey ? actions.goForward : actions.goBack)();
168
+ }
169
+ }
170
+
141
171
  function isEditableShortcutTarget(target: EventTarget | null): boolean {
142
172
  if (!(target instanceof HTMLElement)) {
143
173
  return false;
@@ -156,15 +186,16 @@ function NotificationsMenu({ openSurface }: { openSurface: (surface: SurfaceId)
156
186
  );
157
187
  }
158
188
 
159
- function ConversationWorkspace({ conversationMode, selectedLocalRepoIndex, status }: {
189
+ function ConversationWorkspace({ conversationMode, selectedLocalRepoIndex, selectedSessionId, status }: {
160
190
  conversationMode: ConversationMode;
161
191
  selectedLocalRepoIndex: number;
192
+ selectedSessionId: string | undefined;
162
193
  status: GuiStatusData;
163
194
  }) {
164
195
  const selectedRepo = status.local_repos.repos[selectedLocalRepoIndex] ?? status.local_repos.repos[0];
165
196
  const selectedSession = selectedRepo === undefined
166
197
  ? undefined
167
- : status.opencode_sessions.sessions.find((session) => session.repo_path_ref === selectedRepo.path_ref);
198
+ : status.opencode_sessions.sessions.find((session) => session.id_ref === selectedSessionId && session.repo_path_ref === selectedRepo.path_ref) ?? status.opencode_sessions.sessions.find((session) => session.repo_path_ref === selectedRepo.path_ref);
168
199
  const title = conversationMode === "ai" ? selectedRepo?.name ?? text.opencodeSessions : text.teams;
169
200
 
170
201
  return (
@@ -247,6 +278,7 @@ function SurfaceContent({ activeItem, activeSurface, fileRoot, status }: {
247
278
  const vaultCollection = vaultCollectionForSurface(status.vault, activeSurface);
248
279
  const staticSurfaces: Partial<Record<SurfaceId, ReactNode>> = {
249
280
  overview: <OverviewSurface status={status} />,
281
+ help: <HelpSurface />,
250
282
  settings: <SettingsSurface status={status} />,
251
283
  notifications: <NotificationsSurface />,
252
284
  admin: <AdminSurface />,
@@ -274,7 +306,7 @@ function SurfaceContent({ activeItem, activeSurface, fileRoot, status }: {
274
306
  maintenance: <PlannedSurface label={text.maintenance} detail={text.projectWorkIntro} />,
275
307
  performance: <PlannedSurface label={text.performance} detail={text.projectWorkIntro} />,
276
308
  reports: <PlannedSurface label={text.reports} detail={text.projectWorkIntro} />,
277
- apps: <AppsSurface />,
309
+ apps: <AppsSurface status={status} />,
278
310
  installation: <InstallationSurface />,
279
311
  projects: <ProjectsSurface status={status} />,
280
312
  security: <SecuritySurface status={status} />,
@@ -300,6 +332,40 @@ function SurfaceContent({ activeItem, activeSurface, fileRoot, status }: {
300
332
  return staticSurfaces[activeSurface] ?? null;
301
333
  }
302
334
 
335
+ function HelpSurface(): ReactElement {
336
+ const shortcuts = [
337
+ ["#", "Channels"],
338
+ ["_", "AI sessions"],
339
+ ["&", "Infrastructure"],
340
+ ["?", "Help"],
341
+ [">", "New terminal command"],
342
+ ["+", "Add commands"],
343
+ ["-", "Remove commands"],
344
+ ["/", "Slash commands"],
345
+ ["=", "Comms"],
346
+ ["~", "Settings and config"],
347
+ ["!", "Notifications"],
348
+ [".", "Agents"],
349
+ ["*", "Secrets and passwords"],
350
+ [":", "Emoji picker"],
351
+ ["^", "Copy current page link"],
352
+ ];
353
+
354
+ return (
355
+ <section className="settings-surface help-surface">
356
+ <div className="planned-card">
357
+ <h2>Help</h2>
358
+ <p>Use the command palette shortcuts from anywhere outside text inputs. Recent selections appear first until you keep typing to filter.</p>
359
+ </div>
360
+ <ul className="shortcut-list">
361
+ {shortcuts.map(([shortcut, label]) => <li key={shortcut}><kbd>{shortcut}</kbd><span>{label}</span></li>)}
362
+ <li><kbd>Backspace</kbd><span>Previous page</span></li>
363
+ <li><kbd>Shift Backspace</kbd><span>Next page</span></li>
364
+ </ul>
365
+ </section>
366
+ );
367
+ }
368
+
303
369
  function SettingsSurface({ status }: { status: GuiStatusData }): ReactElement {
304
370
  return (
305
371
  <section className="settings-surface">