@deepseek-harness-tui/dsh-tui 0.10.2 → 0.11.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.
- package/README.md +171 -124
- package/bin/dsh-tui.js +748 -22
- package/cordis.patch.yml +38 -10
- package/cordis.yml +2 -0
- package/lib/types/adapter/channel/session-projection.d.ts.map +1 -1
- package/lib/types/adapter/channel/session-projection.js +2 -0
- package/lib/types/adapter/channel/ui-policy.d.ts +4 -1
- package/lib/types/adapter/channel/ui-policy.d.ts.map +1 -1
- package/lib/types/adapter/channel/ui-policy.js +5 -1
- package/lib/types/adapter/kernel/host-facade.d.ts.map +1 -1
- package/lib/types/adapter/kernel/host-facade.js +1 -0
- package/lib/types/adapter/kernel/replay.d.ts.map +1 -1
- package/lib/types/adapter/kernel/replay.js +3 -0
- package/lib/types/adapter/kernel/runtime.d.ts.map +1 -1
- package/lib/types/adapter/kernel/runtime.js +2 -0
- package/lib/types/adapter/kernel/slices/workspace.d.ts.map +1 -1
- package/lib/types/adapter/kernel/slices/workspace.js +1 -0
- package/lib/types/adapter/ports/channel-ui.d.ts +22 -2
- package/lib/types/adapter/ports/channel-ui.d.ts.map +1 -1
- package/lib/types/adapter/ports/channel-view.d.ts +66 -0
- package/lib/types/adapter/ports/channel-view.d.ts.map +1 -1
- package/lib/types/adapter/ports/channel-workspace.d.ts +23 -0
- package/lib/types/adapter/ports/channel-workspace.d.ts.map +1 -1
- package/lib/types/adapter/ports/workspace.d.ts +2 -0
- package/lib/types/adapter/ports/workspace.d.ts.map +1 -1
- package/lib/types/adapter/upstream/workspace-driver.d.ts.map +1 -1
- package/lib/types/adapter/upstream/workspace-driver.js +10 -2
- package/lib/types/commands.d.ts.map +1 -1
- package/lib/types/commands.js +1 -0
- package/lib/types/components/ContextBarView.d.ts +15 -7
- package/lib/types/components/ContextBarView.d.ts.map +1 -1
- package/lib/types/components/ContextBarView.js +26 -22
- package/lib/types/components/ImagePreviewOverlay.d.ts.map +1 -1
- package/lib/types/components/ImagePreviewOverlay.js +11 -5
- package/lib/types/components/Markdown.d.ts +11 -1
- package/lib/types/components/Markdown.d.ts.map +1 -1
- package/lib/types/components/Markdown.js +20 -2
- package/lib/types/components/MermaidDiagram.d.ts +13 -0
- package/lib/types/components/MermaidDiagram.d.ts.map +1 -0
- package/lib/types/components/MermaidDiagram.js +61 -0
- package/lib/types/components/MessageList.d.ts +26 -1
- package/lib/types/components/MessageList.d.ts.map +1 -1
- package/lib/types/components/MessageList.js +72 -7
- package/lib/types/components/PageMargin.d.ts +1 -1
- package/lib/types/components/PromptInput.d.ts +37 -1
- package/lib/types/components/PromptInput.d.ts.map +1 -1
- package/lib/types/components/PromptInput.js +243 -28
- package/lib/types/components/ScrollbarGutter.d.ts +20 -5
- package/lib/types/components/ScrollbarGutter.d.ts.map +1 -1
- package/lib/types/components/ScrollbarGutter.js +88 -10
- package/lib/types/components/StreamingMarkdown.d.ts.map +1 -1
- package/lib/types/components/StreamingMarkdown.js +30 -28
- package/lib/types/components/Tooltip.d.ts.map +1 -1
- package/lib/types/components/Tooltip.js +45 -7
- package/lib/types/components/messages/UserPromptMessage.d.ts.map +1 -1
- package/lib/types/components/messages/UserPromptMessage.js +5 -7
- package/lib/types/components/messages/useImageInspection.d.ts.map +1 -1
- package/lib/types/components/messages/useImageInspection.js +19 -9
- package/lib/types/components/promptDraftCache.d.ts +102 -0
- package/lib/types/components/promptDraftCache.d.ts.map +1 -0
- package/lib/types/components/promptDraftCache.js +76 -0
- package/lib/types/components/questions/AskUserQuestionPanel.d.ts +17 -1
- package/lib/types/components/questions/AskUserQuestionPanel.d.ts.map +1 -1
- package/lib/types/components/questions/AskUserQuestionPanel.js +40 -7
- package/lib/types/components/questions/QuestionMinimizedBar.d.ts +12 -0
- package/lib/types/components/questions/QuestionMinimizedBar.d.ts.map +1 -0
- package/lib/types/components/questions/QuestionMinimizedBar.js +43 -0
- package/lib/types/components/sessions/SessionListRow.d.ts +23 -1
- package/lib/types/components/sessions/SessionListRow.d.ts.map +1 -1
- package/lib/types/components/sessions/SessionListRow.js +52 -5
- package/lib/types/components/workspaces/HomeWorkspaceRow.d.ts +49 -0
- package/lib/types/components/workspaces/HomeWorkspaceRow.d.ts.map +1 -0
- package/lib/types/components/workspaces/HomeWorkspaceRow.js +50 -0
- package/lib/types/dsh-adapter/bundled-presets.d.ts +10 -0
- package/lib/types/dsh-adapter/bundled-presets.d.ts.map +1 -0
- package/lib/types/dsh-adapter/bundled-presets.js +80 -0
- package/lib/types/dsh-adapter/channel/action-readiness.d.ts +1 -1
- package/lib/types/dsh-adapter/channel/action-readiness.d.ts.map +1 -1
- package/lib/types/dsh-adapter/channel/action-readiness.js +3 -0
- package/lib/types/dsh-adapter/channel/agent-view-projection.d.ts +1 -1
- package/lib/types/dsh-adapter/channel/agent-view-projection.d.ts.map +1 -1
- package/lib/types/dsh-adapter/channel/agent-view-projection.js +19 -2
- package/lib/types/dsh-adapter/channel/background-action.d.ts.map +1 -1
- package/lib/types/dsh-adapter/channel/background-action.js +52 -29
- package/lib/types/dsh-adapter/channel/binding.d.ts +19 -2
- package/lib/types/dsh-adapter/channel/binding.d.ts.map +1 -1
- package/lib/types/dsh-adapter/channel/binding.js +78 -18
- package/lib/types/dsh-adapter/channel/composer-images.d.ts +5 -0
- package/lib/types/dsh-adapter/channel/composer-images.d.ts.map +1 -1
- package/lib/types/dsh-adapter/channel/composer-images.js +105 -6
- package/lib/types/dsh-adapter/channel/context-bookkeeping.d.ts +8 -0
- package/lib/types/dsh-adapter/channel/context-bookkeeping.d.ts.map +1 -1
- package/lib/types/dsh-adapter/channel/context-bookkeeping.js +3 -2
- package/lib/types/dsh-adapter/channel/ide-selection.d.ts +58 -0
- package/lib/types/dsh-adapter/channel/ide-selection.d.ts.map +1 -0
- package/lib/types/dsh-adapter/channel/ide-selection.js +226 -0
- package/lib/types/dsh-adapter/channel/input-delivery.d.ts +6 -1
- package/lib/types/dsh-adapter/channel/input-delivery.d.ts.map +1 -1
- package/lib/types/dsh-adapter/channel/input-delivery.js +9 -1
- package/lib/types/dsh-adapter/channel/local-actions.d.ts +3 -19
- package/lib/types/dsh-adapter/channel/local-actions.d.ts.map +1 -1
- package/lib/types/dsh-adapter/channel/local-actions.js +2 -1
- package/lib/types/dsh-adapter/channel/model-actions.d.ts +4 -1
- package/lib/types/dsh-adapter/channel/model-actions.d.ts.map +1 -1
- package/lib/types/dsh-adapter/channel/model-actions.js +82 -6
- package/lib/types/dsh-adapter/channel/model-switch.d.ts.map +1 -1
- package/lib/types/dsh-adapter/channel/model-switch.js +49 -31
- package/lib/types/dsh-adapter/channel/projection.d.ts +4 -0
- package/lib/types/dsh-adapter/channel/projection.d.ts.map +1 -1
- package/lib/types/dsh-adapter/channel/projection.js +29 -19
- package/lib/types/dsh-adapter/channel/reports.d.ts.map +1 -1
- package/lib/types/dsh-adapter/channel/reports.js +4 -12
- package/lib/types/dsh-adapter/channel/session-fork.d.ts.map +1 -1
- package/lib/types/dsh-adapter/channel/session-fork.js +26 -2
- package/lib/types/dsh-adapter/channel/session-live-adoption.d.ts +3 -0
- package/lib/types/dsh-adapter/channel/session-live-adoption.d.ts.map +1 -1
- package/lib/types/dsh-adapter/channel/session-live-adoption.js +1 -0
- package/lib/types/dsh-adapter/channel/session-metadata.d.ts +10 -0
- package/lib/types/dsh-adapter/channel/session-metadata.d.ts.map +1 -1
- package/lib/types/dsh-adapter/channel/session-metadata.js +2 -2
- package/lib/types/dsh-adapter/channel/session-resume.d.ts +11 -2
- package/lib/types/dsh-adapter/channel/session-resume.d.ts.map +1 -1
- package/lib/types/dsh-adapter/channel/session-resume.js +291 -94
- package/lib/types/dsh-adapter/channel/session-rewind.d.ts.map +1 -1
- package/lib/types/dsh-adapter/channel/session-rewind.js +34 -16
- package/lib/types/dsh-adapter/channel/session-tree-actions.d.ts.map +1 -1
- package/lib/types/dsh-adapter/channel/session-tree-actions.js +19 -3
- package/lib/types/dsh-adapter/channel/settings-host.d.ts.map +1 -1
- package/lib/types/dsh-adapter/channel/settings-host.js +4 -3
- package/lib/types/dsh-adapter/channel/state.d.ts +1 -1
- package/lib/types/dsh-adapter/channel/state.d.ts.map +1 -1
- package/lib/types/dsh-adapter/channel/state.js +1 -1
- package/lib/types/dsh-adapter/channel/transcript.d.ts.map +1 -1
- package/lib/types/dsh-adapter/channel/transcript.js +6 -12
- package/lib/types/dsh-adapter/channel/types.d.ts +6 -1
- package/lib/types/dsh-adapter/channel/types.d.ts.map +1 -1
- package/lib/types/dsh-adapter/channel/workspace-actions.d.ts +4 -1
- package/lib/types/dsh-adapter/channel/workspace-actions.d.ts.map +1 -1
- package/lib/types/dsh-adapter/channel/workspace-actions.js +41 -1
- package/lib/types/dsh-adapter/channel.d.ts.map +1 -1
- package/lib/types/dsh-adapter/channel.js +67 -6
- package/lib/types/dsh-adapter/compat/messages.d.ts +15 -0
- package/lib/types/dsh-adapter/compat/messages.d.ts.map +1 -0
- package/lib/types/dsh-adapter/compat/messages.js +15 -0
- package/lib/types/dsh-adapter/compat/settings.d.ts +33 -0
- package/lib/types/dsh-adapter/compat/settings.d.ts.map +1 -0
- package/lib/types/dsh-adapter/compat/settings.js +43 -0
- package/lib/types/dsh-adapter/compat/shell.d.ts +25 -0
- package/lib/types/dsh-adapter/compat/shell.d.ts.map +1 -0
- package/lib/types/dsh-adapter/compat/shell.js +11 -0
- package/lib/types/dsh-adapter/contract.d.ts +4 -4
- package/lib/types/dsh-adapter/contract.d.ts.map +1 -1
- package/lib/types/dsh-adapter/contract.js +15 -6
- package/lib/types/dsh-adapter/ide-channel.d.ts +224 -0
- package/lib/types/dsh-adapter/ide-channel.d.ts.map +1 -0
- package/lib/types/dsh-adapter/ide-channel.js +556 -0
- package/lib/types/dsh-adapter/index.d.ts +18 -3
- package/lib/types/dsh-adapter/index.d.ts.map +1 -1
- package/lib/types/dsh-adapter/index.js +32 -5
- package/lib/types/dsh-adapter/plugin.d.ts +5 -2
- package/lib/types/dsh-adapter/plugin.d.ts.map +1 -1
- package/lib/types/dsh-adapter/plugin.js +173 -42
- package/lib/types/dsh-adapter/preset-resolution.d.ts +8 -0
- package/lib/types/dsh-adapter/preset-resolution.d.ts.map +1 -1
- package/lib/types/dsh-adapter/preset-resolution.js +10 -0
- package/lib/types/dsh-adapter/session-mount-heartbeat.d.ts +53 -0
- package/lib/types/dsh-adapter/session-mount-heartbeat.d.ts.map +1 -0
- package/lib/types/dsh-adapter/session-mount-heartbeat.js +100 -0
- package/lib/types/dsh-adapter/sessionTree.d.ts.map +1 -1
- package/lib/types/dsh-adapter/sessionTree.js +3 -2
- package/lib/types/dsh-adapter/subagents.d.ts.map +1 -1
- package/lib/types/dsh-adapter/subagents.js +6 -5
- package/lib/types/dsh-adapter/workspaces.d.ts +46 -3
- package/lib/types/dsh-adapter/workspaces.d.ts.map +1 -1
- package/lib/types/dsh-adapter/workspaces.js +116 -0
- package/lib/types/effortPrefs.d.ts +15 -3
- package/lib/types/effortPrefs.d.ts.map +1 -1
- package/lib/types/effortPrefs.js +37 -3
- package/lib/types/homePrefs.d.ts +15 -0
- package/lib/types/homePrefs.d.ts.map +1 -0
- package/lib/types/homePrefs.js +53 -0
- package/lib/types/i18n.d.ts +257 -296
- package/lib/types/i18n.d.ts.map +1 -1
- package/lib/types/i18n.js +78 -84
- package/lib/types/ink/colorize.d.ts +10 -0
- package/lib/types/ink/colorize.d.ts.map +1 -1
- package/lib/types/ink/colorize.js +21 -0
- package/lib/types/ink/components/ScrollBox.d.ts.map +1 -1
- package/lib/types/ink/components/ScrollBox.js +12 -1
- package/lib/types/ink/dom.d.ts +1 -0
- package/lib/types/ink/dom.d.ts.map +1 -1
- package/lib/types/ink/hooks/use-copy-on-select.d.ts +5 -1
- package/lib/types/ink/hooks/use-copy-on-select.d.ts.map +1 -1
- package/lib/types/ink/hooks/use-copy-on-select.js +15 -1
- package/lib/types/ink/ink.d.ts.map +1 -1
- package/lib/types/ink/ink.js +36 -1
- package/lib/types/ink/output.d.ts +10 -1
- package/lib/types/ink/output.d.ts.map +1 -1
- package/lib/types/ink/output.js +109 -17
- package/lib/types/ink/render-border.d.ts +6 -1
- package/lib/types/ink/render-border.d.ts.map +1 -1
- package/lib/types/ink/render-border.js +25 -9
- package/lib/types/ink/render-node-to-output.d.ts.map +1 -1
- package/lib/types/ink/render-node-to-output.js +48 -6
- package/lib/types/ink/renderer.d.ts +6 -0
- package/lib/types/ink/renderer.d.ts.map +1 -1
- package/lib/types/ink/renderer.js +2 -1
- package/lib/types/ink/selection.d.ts +60 -0
- package/lib/types/ink/selection.d.ts.map +1 -1
- package/lib/types/ink/selection.js +129 -0
- package/lib/types/ink/sixel-graphics.d.ts +19 -1
- package/lib/types/ink/sixel-graphics.d.ts.map +1 -1
- package/lib/types/ink/sixel-graphics.js +124 -12
- package/lib/types/ink/terminal-image.d.ts +18 -1
- package/lib/types/ink/terminal-image.d.ts.map +1 -1
- package/lib/types/ink/terminal.d.ts +17 -0
- package/lib/types/ink/terminal.d.ts.map +1 -1
- package/lib/types/ink/terminal.js +20 -1
- package/lib/types/screens/Chat.d.ts +17 -1
- package/lib/types/screens/Chat.d.ts.map +1 -1
- package/lib/types/screens/Chat.js +284 -72
- package/lib/types/screens/SessionSupervisor.d.ts +84 -0
- package/lib/types/screens/SessionSupervisor.d.ts.map +1 -0
- package/lib/types/screens/SessionSupervisor.js +350 -0
- package/lib/types/screens/SessionTree.js +2 -2
- package/lib/types/screens/Settings.d.ts.map +1 -1
- package/lib/types/screens/Settings.js +32 -9
- package/lib/types/screens/StatusLine.d.ts +13 -0
- package/lib/types/screens/StatusLine.d.ts.map +1 -1
- package/lib/types/screens/StatusLine.js +36 -17
- package/lib/types/screens/StatusMetrics.d.ts +79 -22
- package/lib/types/screens/StatusMetrics.d.ts.map +1 -1
- package/lib/types/screens/StatusMetrics.js +153 -88
- package/lib/types/screens/sessionSupervisor/model.d.ts +83 -0
- package/lib/types/screens/sessionSupervisor/model.d.ts.map +1 -0
- package/lib/types/screens/sessionSupervisor/model.js +69 -0
- package/lib/types/screens/sessionSupervisor/useSessionSupervisor.d.ts +120 -0
- package/lib/types/screens/sessionSupervisor/useSessionSupervisor.d.ts.map +1 -0
- package/lib/types/screens/sessionSupervisor/useSessionSupervisor.js +593 -0
- package/lib/types/sessionMounts.d.ts +272 -0
- package/lib/types/sessionMounts.d.ts.map +1 -0
- package/lib/types/sessionMounts.js +706 -0
- package/lib/types/sessions/format.d.ts.map +1 -1
- package/lib/types/sessions/format.js +4 -1
- package/lib/types/sessions/resumeFailure.d.ts +38 -0
- package/lib/types/sessions/resumeFailure.d.ts.map +1 -0
- package/lib/types/sessions/resumeFailure.js +58 -0
- package/lib/types/terminal-utils/mermaid.d.ts +44 -0
- package/lib/types/terminal-utils/mermaid.d.ts.map +1 -0
- package/lib/types/terminal-utils/mermaid.js +83 -0
- package/lib/types/tips.d.ts.map +1 -1
- package/lib/types/tips.js +175 -19
- package/lib/types/tuiDisplayPrefs.d.ts +7 -9
- package/lib/types/tuiDisplayPrefs.d.ts.map +1 -1
- package/lib/types/tuiDisplayPrefs.js +31 -28
- package/lib/types/update.d.ts +61 -0
- package/lib/types/update.d.ts.map +1 -1
- package/lib/types/update.js +194 -0
- package/lib/types/utils/imageResize.d.ts +90 -0
- package/lib/types/utils/imageResize.d.ts.map +1 -0
- package/lib/types/utils/imageResize.js +272 -0
- package/lib/types/utils/keymap.d.ts +5 -1
- package/lib/types/utils/keymap.d.ts.map +1 -1
- package/lib/types/utils/keymap.js +23 -0
- package/lib/types/workspaces.d.ts +2 -1
- package/lib/types/workspaces.d.ts.map +1 -1
- package/lib/types/workspaces.js +1 -1
- package/node_modules/@deepseek-harness-tui/dsh-auth/package.json +10 -10
- package/package.json +120 -103
- package/presets/liangshen/.dsh-tui-managed.json +1 -1
- package/presets/liangshen/agent.cordis.yml +15 -0
- package/lib/types/screens/AgentView.d.ts +0 -23
- package/lib/types/screens/AgentView.d.ts.map +0 -1
- package/lib/types/screens/AgentView.js +0 -546
- package/lib/types/screens/SessionBrowser.d.ts +0 -42
- package/lib/types/screens/SessionBrowser.d.ts.map +0 -1
- package/lib/types/screens/SessionBrowser.js +0 -911
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Data, derived state and actions for the unified session screen.
|
|
3
|
+
*
|
|
4
|
+
* The screen keeps only assembly and key routing; everything that answers "what
|
|
5
|
+
* is on screen and what does an action do" lives here. That is what makes the
|
|
6
|
+
* two panes presentational and the derivations reachable from a regression.
|
|
7
|
+
*
|
|
8
|
+
* Two ownership rules must not be duplicated anywhere else:
|
|
9
|
+
* - the cursor is ONE fact; every index is derived from it, so movement,
|
|
10
|
+
* rendering and Enter can never disagree;
|
|
11
|
+
* - occupancy is re-read from the ledger on this screen own pulse and never
|
|
12
|
+
* captured from the parent render.
|
|
13
|
+
*/
|
|
14
|
+
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
|
15
|
+
import { basename } from 'node:path';
|
|
16
|
+
import { t } from '../../i18n.js';
|
|
17
|
+
import { normalizeWorkspaceCwd } from '../../sessions/view.js';
|
|
18
|
+
import { readSessionPins, setSessionPinned } from '../../sessionPins.js';
|
|
19
|
+
import { readSessionOwners } from '../../sessionMounts.js';
|
|
20
|
+
import { RAIL_CHROME_ROWS, WORKSPACE_ROW_LINES, RAIL_MIN_TOTAL_COLUMNS, RAIL_WIDTH_MIN, RAIL_WIDTH_MAX, SESSION_ROW_LINES, SESSION_PANE_CHROME_ROWS, MENU_ACTIONS, UNREGISTERED_RAIL_ID, message, samePath, sessionMatchesQuery } from './model.js';
|
|
21
|
+
/**
|
|
22
|
+
* Derive the whole screen model.
|
|
23
|
+
* @param input - Channel, home, the opening/stop actions and the live lookup.
|
|
24
|
+
* @returns Every value the screen renders from, plus the action callbacks.
|
|
25
|
+
*/
|
|
26
|
+
export function useSessionSupervisor(input) {
|
|
27
|
+
const { channel, home, onOpenSession, onNewSession, onStopSession, liveStateOf, columns, rows } = input;
|
|
28
|
+
const [entries, setEntries] = useState([]);
|
|
29
|
+
const [sessions, setSessions] = useState([]);
|
|
30
|
+
const [loading, setLoading] = useState(true);
|
|
31
|
+
const [notice, setNotice] = useState(undefined);
|
|
32
|
+
/** Live status and occupancy are re-read on their own clock, not the listing's. */
|
|
33
|
+
const [pulse, setPulse] = useState(0);
|
|
34
|
+
const [query, setQuery] = useState('');
|
|
35
|
+
/**
|
|
36
|
+
* Cross-process occupancy, re-read on the SAME tick as the live state.
|
|
37
|
+
*
|
|
38
|
+
* This has to be read here, not captured by the host: the ledger is a file
|
|
39
|
+
* another process writes, so a snapshot taken during the parent's render went
|
|
40
|
+
* stale the moment it was taken and nothing re-took it — the parent does not
|
|
41
|
+
* re-render on this screen's 2s pulse, so a foreign terminal that acquired or
|
|
42
|
+
* released a session left the row red (and unclickable) until some unrelated
|
|
43
|
+
* channel event happened to repaint. Reading behind a pulse-keyed ref keeps
|
|
44
|
+
* it to one read per tick while the rows and the click guard always see the
|
|
45
|
+
* current holder.
|
|
46
|
+
*/
|
|
47
|
+
const occupancyRef = useRef(new Map());
|
|
48
|
+
const occupancyPulseRef = useRef(-1);
|
|
49
|
+
if (occupancyPulseRef.current !== pulse) {
|
|
50
|
+
occupancyPulseRef.current = pulse;
|
|
51
|
+
occupancyRef.current = readSessionOwners();
|
|
52
|
+
}
|
|
53
|
+
const holderOf = useCallback((sessionId) => {
|
|
54
|
+
const owner = occupancyRef.current.get(sessionId);
|
|
55
|
+
return owner === undefined || owner.pid === process.pid ? undefined : owner.pid;
|
|
56
|
+
}, []);
|
|
57
|
+
/**
|
|
58
|
+
* Sessions eligible for this screen, computed ONCE per listing so the rail's
|
|
59
|
+
* per-workspace counts and the pane's rows always agree.
|
|
60
|
+
*
|
|
61
|
+
* Three things are hidden, and the third is the one that is easy to lose:
|
|
62
|
+
* a delegated run (its own row belongs to the agent-run folding, not to a
|
|
63
|
+
* workspace listing), a log holding no conversation, and the CURRENT
|
|
64
|
+
* session's fork ANCESTORS. The last one matters because a `/resume` fork
|
|
65
|
+
* records `parentSession` exactly like a delegated run does — listing the
|
|
66
|
+
* chain makes one conversation look like several, with no way to tell which
|
|
67
|
+
* row continues what the user is looking at.
|
|
68
|
+
*
|
|
69
|
+
* The current session itself stays listed (marked `current` by the live
|
|
70
|
+
* state): this screen exists to show what the terminal hosts, and "the one
|
|
71
|
+
* you are in" is the row the user is most likely looking for. Only the
|
|
72
|
+
* ancestors go — they are the same conversation at an earlier point, which
|
|
73
|
+
* the current row already represents. `buildView` hides the current id as
|
|
74
|
+
* well because its list has no live-state column to mark it with.
|
|
75
|
+
*/
|
|
76
|
+
const listedSessions = useMemo(() => {
|
|
77
|
+
const byId = new Map(sessions.map(session => [session.id, session]));
|
|
78
|
+
const ancestors = new Set();
|
|
79
|
+
let cursor = byId.get(channel.agentId);
|
|
80
|
+
while (cursor?.kind.kind === 'fork') {
|
|
81
|
+
const parent = cursor.kind.parent;
|
|
82
|
+
if (parent === undefined || ancestors.has(parent))
|
|
83
|
+
break;
|
|
84
|
+
ancestors.add(parent);
|
|
85
|
+
cursor = byId.get(parent);
|
|
86
|
+
}
|
|
87
|
+
return sessions.filter(session => session.hasPrompt && session.kind.kind !== 'subagent' && !ancestors.has(session.id));
|
|
88
|
+
}, [sessions, channel.agentId]);
|
|
89
|
+
/**
|
|
90
|
+
* The rail's rows: the durable registry, plus rows for sessions that live in
|
|
91
|
+
* a directory the registry does not know (or while it cannot be read at all).
|
|
92
|
+
*
|
|
93
|
+
* The registry is the sidebar's own ledger, and it is genuinely OPTIONAL:
|
|
94
|
+
* `createLocalWorkspaceRuntime()` supports compositions with no workspace
|
|
95
|
+
* stack and returns an empty one, a registration can be removed while its
|
|
96
|
+
* session logs stay on disk, and the service itself can reject. Sessions in
|
|
97
|
+
* every one of those cases are still resumable — the persistence store, not
|
|
98
|
+
* the registry, is what holds them — so an empty rail must not render "no
|
|
99
|
+
* history" and leave them unreachable.
|
|
100
|
+
*
|
|
101
|
+
* Each unregistered directory gets its OWN row (titled by its basename), so
|
|
102
|
+
* the rail keeps telling the user WHERE a session ran; when even the session
|
|
103
|
+
* paths are unavailable they all land in one synthetic group. These rows live
|
|
104
|
+
* only inside this screen and are never written back to the ledger.
|
|
105
|
+
*/
|
|
106
|
+
const railEntries = useMemo(() => {
|
|
107
|
+
const orphans = listedSessions.filter(session => !entries.some(entry => samePath(entry.path, session.cwd)));
|
|
108
|
+
if (orphans.length === 0)
|
|
109
|
+
return entries;
|
|
110
|
+
const byCwd = new Map();
|
|
111
|
+
for (const session of orphans) {
|
|
112
|
+
const key = normalizeWorkspaceCwd(session.cwd);
|
|
113
|
+
const existing = byCwd.get(key);
|
|
114
|
+
if (existing === undefined)
|
|
115
|
+
byCwd.set(key, { path: session.cwd, count: 1 });
|
|
116
|
+
else
|
|
117
|
+
existing.count += 1;
|
|
118
|
+
}
|
|
119
|
+
return [...entries, ...[...byCwd.entries()].map(([key, group]) => ({
|
|
120
|
+
id: `${UNREGISTERED_RAIL_ID}:${key === '' ? 'unknown' : key}`,
|
|
121
|
+
path: group.path === '' ? UNREGISTERED_RAIL_ID : group.path,
|
|
122
|
+
title: basename(group.path) || t('supervisor-unregistered'),
|
|
123
|
+
present: true,
|
|
124
|
+
sessionCount: group.count,
|
|
125
|
+
from: 'unregistered',
|
|
126
|
+
}))];
|
|
127
|
+
}, [entries, listedSessions]);
|
|
128
|
+
const groupedEntries = useMemo(() => {
|
|
129
|
+
const groups = new Map();
|
|
130
|
+
for (const session of listedSessions) {
|
|
131
|
+
const path = railEntries.find(entry => entry.from === 'registry' && samePath(entry.path, session.cwd))?.path
|
|
132
|
+
?? (session.cwd === '' ? UNREGISTERED_RAIL_ID : session.cwd);
|
|
133
|
+
const bucket = groups.get(path);
|
|
134
|
+
if (bucket === undefined)
|
|
135
|
+
groups.set(path, [session]);
|
|
136
|
+
else
|
|
137
|
+
bucket.push(session);
|
|
138
|
+
}
|
|
139
|
+
return groups;
|
|
140
|
+
}, [listedSessions, railEntries]);
|
|
141
|
+
/** Session count for one rail row, from the same grouping the pane uses. */
|
|
142
|
+
const countOf = useCallback((entry) => (groupedEntries.get(entry.path) ?? []).length, [groupedEntries]);
|
|
143
|
+
const [railFocus, setRailFocus] = useState(0);
|
|
144
|
+
/**
|
|
145
|
+
* A rail entry the user picked by hand that is NOT registered — the fallback
|
|
146
|
+
* group for unregistered sessions. It exists only for this screen's lifetime:
|
|
147
|
+
* selecting a group is a way to SEE those sessions, never a way to register a
|
|
148
|
+
* directory, so it must not create a ledger record.
|
|
149
|
+
*/
|
|
150
|
+
const [selectedUnregistered, setSelectedUnregistered] = useState(false);
|
|
151
|
+
const [selectedPath, setSelectedPath] = useState(undefined);
|
|
152
|
+
/** True once the user picked a rail row by hand; see the selection effect. */
|
|
153
|
+
const [selectionManual, setSelectionManual] = useState(false);
|
|
154
|
+
/**
|
|
155
|
+
* The session column's cursor, as ONE fact.
|
|
156
|
+
*
|
|
157
|
+
* It used to be three: a `sessionFocus` index, a `sessionFocusRef` mirror and
|
|
158
|
+
* a `focusSessionId`, with the render deriving the index from the id while
|
|
159
|
+
* Enter read the ref. The filter and the live-state re-sort move rows, so the
|
|
160
|
+
* ref went stale and Enter opened a row other than the one under `❯`. The id
|
|
161
|
+
* (or the card) is stored here and the index is always DERIVED from it.
|
|
162
|
+
*/
|
|
163
|
+
/**
|
|
164
|
+
* The cursor of the session column, as ONE fact: the session id it is on, or
|
|
165
|
+
* undefined for the new-session card.
|
|
166
|
+
*
|
|
167
|
+
* The screen used to keep an index, a ref mirror and an id beside each other,
|
|
168
|
+
* with the render deriving an index from the id while Enter read the ref. The
|
|
169
|
+
* filter and the live-state re-sort move rows, so the stored index kept
|
|
170
|
+
* pointing at the offset it had held while `❯` was drawn from the id — Enter
|
|
171
|
+
* then acted on a row the user had never selected. There is one fact now, and
|
|
172
|
+
* {@link focusIndex} is derived from it for the render, for movement and for
|
|
173
|
+
* Enter alike.
|
|
174
|
+
*/
|
|
175
|
+
const [focusSessionId, setFocusSessionId] = useState(undefined);
|
|
176
|
+
/**
|
|
177
|
+
* Which column owns the keyboard, and therefore which column draws the `❯`
|
|
178
|
+
* cursor. Exactly one at a time: two cursors mean "where does Enter go?" has
|
|
179
|
+
* no answer, and ←/→ is how this screen answers it.
|
|
180
|
+
*/
|
|
181
|
+
const [activePane, setActivePane] = useState('rail');
|
|
182
|
+
const [pins, setPins] = useState(() => readSessionPins());
|
|
183
|
+
const [menu, setMenu] = useState(undefined);
|
|
184
|
+
const [rename, setRename] = useState(undefined);
|
|
185
|
+
const [confirmRemove, setConfirmRemove] = useState(undefined);
|
|
186
|
+
const railRef = useRef(railFocus);
|
|
187
|
+
railRef.current = railFocus;
|
|
188
|
+
const menuRef = useRef(menu);
|
|
189
|
+
menuRef.current = menu;
|
|
190
|
+
const queryRef = useRef(query);
|
|
191
|
+
queryRef.current = query;
|
|
192
|
+
const now = Date.now();
|
|
193
|
+
/**
|
|
194
|
+
* One cheap tick that re-derives live status and cross-process occupancy.
|
|
195
|
+
*
|
|
196
|
+
* Deliberately NOT a re-listing: the listing is the expensive part (a stat
|
|
197
|
+
* per session and a revision-keyed digest), while status and occupancy are
|
|
198
|
+
* two in-memory reads over data the process already holds. So the tick is
|
|
199
|
+
* affordable at a rate that keeps `/resume` honest about a sibling
|
|
200
|
+
* terminal — the user sees another TUI take or release a session while
|
|
201
|
+
* looking at the screen, without this screen re-reading the session store.
|
|
202
|
+
*/
|
|
203
|
+
React.useEffect(() => {
|
|
204
|
+
const timer = setInterval(() => setPulse(value => value + 1), 2000);
|
|
205
|
+
return () => clearInterval(timer);
|
|
206
|
+
}, []);
|
|
207
|
+
/**
|
|
208
|
+
* Reload the ledger and the session listing together.
|
|
209
|
+
*
|
|
210
|
+
* One `listSessions()` pass feeds every workspace: re-reading the whole
|
|
211
|
+
* store per workspace click would be both slower and inconsistent between
|
|
212
|
+
* the two panes.
|
|
213
|
+
*
|
|
214
|
+
* The ledger read degrades to an EMPTY rail when the host does not expose it,
|
|
215
|
+
* instead of failing the whole reload: `/bg` opens this screen, and a host
|
|
216
|
+
* written before the workspace ledger existed (the older in-repo regressions
|
|
217
|
+
* compose exactly that) would otherwise get "failed to read sessions" on
|
|
218
|
+
* screen and lose the session listing with it. The session list is the half
|
|
219
|
+
* this screen cannot work without, so it must survive a missing ledger.
|
|
220
|
+
*/
|
|
221
|
+
const reload = useCallback(async () => {
|
|
222
|
+
// The two reads are independent, and the session listing is the half this
|
|
223
|
+
// screen cannot work without: a registry that rejects (bare composition,
|
|
224
|
+
// unmounted service, a provider throwing) must not take the history down
|
|
225
|
+
// with it. So the listing is settled on its own, and a registry failure
|
|
226
|
+
// degrades to the empty rail the cwd-derived fallback groups already cover.
|
|
227
|
+
await Promise.all([
|
|
228
|
+
(async () => {
|
|
229
|
+
try {
|
|
230
|
+
setSessions(await channel.listSessions());
|
|
231
|
+
setNotice(current => (current?.tone === 'error' ? undefined : current));
|
|
232
|
+
}
|
|
233
|
+
catch (error) {
|
|
234
|
+
setNotice({ text: t('home-sessions-failed', { err: message(error) }), tone: 'error' });
|
|
235
|
+
}
|
|
236
|
+
})(),
|
|
237
|
+
(async () => {
|
|
238
|
+
try {
|
|
239
|
+
const registry = typeof channel.listWorkspaceRegistry === 'function'
|
|
240
|
+
? await channel.listWorkspaceRegistry()
|
|
241
|
+
: [];
|
|
242
|
+
setEntries(registry.map(entry => ({ ...entry, from: 'registry' })));
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
// An unreadable registry is not an empty history: the sessions stay
|
|
246
|
+
// listed (and resumable) under the cwd-derived fallback groups.
|
|
247
|
+
setEntries([]);
|
|
248
|
+
}
|
|
249
|
+
})(),
|
|
250
|
+
]);
|
|
251
|
+
setLoading(false);
|
|
252
|
+
}, [channel]);
|
|
253
|
+
React.useEffect(() => {
|
|
254
|
+
void reload();
|
|
255
|
+
}, [reload]);
|
|
256
|
+
// Selection follows the terminal's own directory, then the ledger: the rail
|
|
257
|
+
// must open on the workspace this terminal is IN, not on whichever record
|
|
258
|
+
// sorts first — otherwise launching in a workspace you have never opened
|
|
259
|
+
// lands the marker on some unrelated project.
|
|
260
|
+
//
|
|
261
|
+
// It deliberately does NOT latch the first default it computes. The listing
|
|
262
|
+
// arrives asynchronously, so the very first pass runs against an EMPTY ledger;
|
|
263
|
+
// latching there would pin the rail to whatever record arrives first and never
|
|
264
|
+
// reconsider. While the selection is still ours to make, every ledger update
|
|
265
|
+
// re-derives it (`entries[0]` remains the fallback when the terminal's own
|
|
266
|
+
// directory is not registered); once the user picks a row by hand, that choice
|
|
267
|
+
// wins until its entry disappears.
|
|
268
|
+
//
|
|
269
|
+
// `channel.cwd` is a dependency, not just a first read: resuming a session
|
|
270
|
+
// from another workspace moves this terminal to that workspace, and the rail
|
|
271
|
+
// follows the session the pane is showing rather than the launch directory.
|
|
272
|
+
React.useEffect(() => {
|
|
273
|
+
if (railEntries.length === 0) {
|
|
274
|
+
if (selectedPath !== undefined && !selectionManual)
|
|
275
|
+
setSelectedPath(undefined);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
if (selectionManual
|
|
279
|
+
&& selectedPath !== undefined
|
|
280
|
+
&& railEntries.some(entry => entry.from === 'registry' && samePath(entry.path, selectedPath)))
|
|
281
|
+
return;
|
|
282
|
+
// A hand-picked fallback group stays picked while it is still on the rail.
|
|
283
|
+
// Without this the group would be dropped on the very next listing pass and
|
|
284
|
+
// the sessions it was showing would vanish again.
|
|
285
|
+
if (selectionManual && selectedUnregistered
|
|
286
|
+
&& railEntries.some(entry => entry.from === 'unregistered'))
|
|
287
|
+
return;
|
|
288
|
+
const here = railEntries.find(entry => entry.from === 'registry' && samePath(entry.path, channel.cwd));
|
|
289
|
+
const next = here ?? railEntries[0];
|
|
290
|
+
setSelectedPath(next.from === 'registry' ? next.path : undefined);
|
|
291
|
+
setSelectedUnregistered(next.from === 'unregistered');
|
|
292
|
+
// The cursor travels with an automatic selection. It starts at 0, so
|
|
293
|
+
// leaving it there while the selection lands elsewhere paints two green
|
|
294
|
+
// rows — `❯` on the first record and the marker on the selected one — until
|
|
295
|
+
// some input moves it. The two are one position on this screen, and the
|
|
296
|
+
// very first frame has to render that way. A pick the user made by hand is
|
|
297
|
+
// left alone (it already moved the cursor itself).
|
|
298
|
+
setRailFocus(current => {
|
|
299
|
+
const index = railEntries.findIndex(entry => entry.id === next.id);
|
|
300
|
+
return index < 0 || current === index ? current : index;
|
|
301
|
+
});
|
|
302
|
+
}, [railEntries, selectedPath, selectedUnregistered, selectionManual, channel.cwd]);
|
|
303
|
+
// The cursor indexes the entry list directly (there is no `+` row in front of
|
|
304
|
+
// it), so a shrinking ledger has to pull it back inside or the last row would
|
|
305
|
+
// highlight nothing.
|
|
306
|
+
React.useEffect(() => {
|
|
307
|
+
setRailFocus(current => Math.min(current, Math.max(0, railEntries.length - 1)));
|
|
308
|
+
}, [railEntries.length]);
|
|
309
|
+
/** The rail row whose sessions the pane is showing (or the fallback group). */
|
|
310
|
+
const selected = useMemo(() => {
|
|
311
|
+
const registered = railEntries.find(entry => entry.from === 'registry' && selectedPath !== undefined && samePath(entry.path, selectedPath));
|
|
312
|
+
if (registered !== undefined)
|
|
313
|
+
return registered;
|
|
314
|
+
if (selectedUnregistered)
|
|
315
|
+
return railEntries.find(entry => entry.from === 'unregistered');
|
|
316
|
+
return railEntries[0];
|
|
317
|
+
}, [railEntries, selectedPath, selectedUnregistered]);
|
|
318
|
+
/**
|
|
319
|
+
* Sessions whose recorded cwd is the selected workspace, minus the search
|
|
320
|
+
* filter. Live sessions in this workspace sort above stopped ones, then by
|
|
321
|
+
* recency: what this terminal is currently running is what the user is
|
|
322
|
+
* most likely switching between.
|
|
323
|
+
*/
|
|
324
|
+
const visibleSessions = useMemo(() => {
|
|
325
|
+
if (selected === undefined)
|
|
326
|
+
return [];
|
|
327
|
+
const needle = query.trim().toLowerCase();
|
|
328
|
+
void pulse;
|
|
329
|
+
return (groupedEntries.get(selected.path) ?? [])
|
|
330
|
+
.filter(session => sessionMatchesQuery(session, needle))
|
|
331
|
+
.slice()
|
|
332
|
+
.sort((left, right) => {
|
|
333
|
+
const leftLive = liveStateOf(left.id)?.live === true ? 1 : 0;
|
|
334
|
+
const rightLive = liveStateOf(right.id)?.live === true ? 1 : 0;
|
|
335
|
+
return rightLive - leftLive || right.updatedAt - left.updatedAt;
|
|
336
|
+
});
|
|
337
|
+
}, [groupedEntries, selected, query, liveStateOf, pulse]);
|
|
338
|
+
/**
|
|
339
|
+
* Cursor identity: rows reorder on every reload and filter, so the cursor
|
|
340
|
+
* follows an ID rather than an index — and the cursor space includes the
|
|
341
|
+
* new-session card as row 0. The card is not decoration: it has to be
|
|
342
|
+
* selectable like every other card, or the keyboard loses a path to the one
|
|
343
|
+
* action that still works when the list is empty.
|
|
344
|
+
*/
|
|
345
|
+
const sessionIndex = useMemo(() => {
|
|
346
|
+
// No focused session means the card (row 0) holds the cursor.
|
|
347
|
+
if (focusSessionId === undefined)
|
|
348
|
+
return 0;
|
|
349
|
+
const byId = visibleSessions.findIndex(session => session.id === focusSessionId);
|
|
350
|
+
if (byId >= 0)
|
|
351
|
+
return byId + 1;
|
|
352
|
+
// The focused session is not on screen (the filter removed it). The cursor
|
|
353
|
+
// must still stand on a REAL row, because Enter acts on whatever it stands
|
|
354
|
+
// on: landing on the card would turn "search, then Enter" into "start a new
|
|
355
|
+
// session" — an action the user never asked for. With an empty match set the
|
|
356
|
+
// card is the only row there is, so it keeps the cursor.
|
|
357
|
+
return visibleSessions.length === 0 ? 0 : 1;
|
|
358
|
+
}, [visibleSessions, focusSessionId]);
|
|
359
|
+
/** True while the new-session card holds the cursor; the render says why. */
|
|
360
|
+
const cardFocused = activePane === 'list' && sessionIndex === 0;
|
|
361
|
+
/** The new-session card is the list's row 0; sessions start at 1. */
|
|
362
|
+
const sessionAt = useCallback((index) => visibleSessions[index - 1], [visibleSessions]);
|
|
363
|
+
/**
|
|
364
|
+
* Enter the session column: land the cursor on the session this terminal is
|
|
365
|
+
* attached to (that is the one the user most likely means), else on the top
|
|
366
|
+
* row — which is the new-session card when the list sorted its live rows
|
|
367
|
+
* lower. A cursor that stayed put while the list scrolled elsewhere would act
|
|
368
|
+
* on a row the user never looked at.
|
|
369
|
+
*/
|
|
370
|
+
const activateList = useCallback(() => {
|
|
371
|
+
setActivePane('list');
|
|
372
|
+
const current = visibleSessions.find(session => liveStateOf(session.id)?.current === true);
|
|
373
|
+
setFocusSessionId(current?.id);
|
|
374
|
+
}, [liveStateOf, visibleSessions]);
|
|
375
|
+
/** Enter the workspace column. */
|
|
376
|
+
const activateRail = useCallback(() => {
|
|
377
|
+
setActivePane('rail');
|
|
378
|
+
}, []);
|
|
379
|
+
const railWidth = columns >= RAIL_MIN_TOTAL_COLUMNS
|
|
380
|
+
? Math.min(RAIL_WIDTH_MAX, Math.max(RAIL_WIDTH_MIN, Math.floor(columns * 0.3)))
|
|
381
|
+
: columns;
|
|
382
|
+
const railVisible = columns >= RAIL_MIN_TOTAL_COLUMNS;
|
|
383
|
+
const sessionWidth = Math.max(20, columns - (railVisible ? railWidth + 1 : 0));
|
|
384
|
+
const railListHeight = Math.max(1, rows - RAIL_CHROME_ROWS);
|
|
385
|
+
/**
|
|
386
|
+
* How many WHOLE workspaces the rail can show. `HomeWorkspaceRow` is always
|
|
387
|
+
* {@link WORKSPACE_ROW_LINES} rows, so the row budget has to be divided by
|
|
388
|
+
* that before it can be used as a window size; handing the row count to the
|
|
389
|
+
* window math directly let the list render twice as many entries as fit and
|
|
390
|
+
* clipped the focused one out of the viewport.
|
|
391
|
+
*/
|
|
392
|
+
const railEntryCapacity = Math.max(1, Math.floor(railListHeight / WORKSPACE_ROW_LINES));
|
|
393
|
+
const sessionListHeight = Math.max(SESSION_ROW_LINES, rows - SESSION_PANE_CHROME_ROWS);
|
|
394
|
+
const report = useCallback((text, tone) => {
|
|
395
|
+
setNotice({ text, tone });
|
|
396
|
+
}, []);
|
|
397
|
+
const persistPin = useCallback((id, pinned) => {
|
|
398
|
+
const result = setSessionPinned(id, pinned);
|
|
399
|
+
if (!result.ok) {
|
|
400
|
+
report(t('resume-pin-save-failed'), 'error');
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
setPins(result.pins);
|
|
404
|
+
}, [report]);
|
|
405
|
+
const selectEntry = useCallback((entry) => {
|
|
406
|
+
setSelectedPath(entry.from === 'registry' ? entry.path : undefined);
|
|
407
|
+
setSelectedUnregistered(entry.from === 'unregistered');
|
|
408
|
+
setSelectionManual(true);
|
|
409
|
+
setFocusSessionId(undefined);
|
|
410
|
+
}, []);
|
|
411
|
+
/**
|
|
412
|
+
* Mount a session, refusing one another terminal holds.
|
|
413
|
+
*
|
|
414
|
+
* The occupancy test runs here as well as in the adapter: the adapter's is
|
|
415
|
+
* the authority (it must hold for every caller), but checking first lets the
|
|
416
|
+
* screen say WHICH terminal owns the session instead of reporting a generic
|
|
417
|
+
* failure, and keeps a refused row from looking like a broken one.
|
|
418
|
+
*/
|
|
419
|
+
const openSession = useCallback((session) => {
|
|
420
|
+
const holder = holderOf(session.id);
|
|
421
|
+
if (holder !== undefined) {
|
|
422
|
+
report(t('supervisor-occupied', { pid: holder }), 'error');
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
setNotice(undefined);
|
|
426
|
+
void onOpenSession(session.id)
|
|
427
|
+
.then((ok) => {
|
|
428
|
+
// The host owns the REASON: it is the layer that saw the mount result
|
|
429
|
+
// (Chat renders the real refusal through `resumeFailureText` and a
|
|
430
|
+
// notification). This screen only names WHICH session could not be
|
|
431
|
+
// entered — a notice that restated the generic failure would compete
|
|
432
|
+
// with, and read worse than, the host's own sentence.
|
|
433
|
+
if (!ok)
|
|
434
|
+
report(t('supervisor-open-failed', { name: session.title.text }), 'error');
|
|
435
|
+
})
|
|
436
|
+
.catch(error => report(t('session-resume-failed', { err: message(error) }), 'error'));
|
|
437
|
+
}, [holderOf, onOpenSession, report]);
|
|
438
|
+
const newSessionIn = useCallback((entry) => {
|
|
439
|
+
setNotice(undefined);
|
|
440
|
+
void channel.resolveWorkspace(entry.path)
|
|
441
|
+
.then((target) => {
|
|
442
|
+
if (target === undefined) {
|
|
443
|
+
report(t('workspace-open-invalid', { target: entry.path }), 'error');
|
|
444
|
+
return undefined;
|
|
445
|
+
}
|
|
446
|
+
return onNewSession(target).then((ok) => {
|
|
447
|
+
if (!ok)
|
|
448
|
+
report(t('new-session-failed', { err: '' }), 'error');
|
|
449
|
+
return ok;
|
|
450
|
+
});
|
|
451
|
+
})
|
|
452
|
+
.catch(error => report(t('new-session-failed', { err: message(error) }), 'error'));
|
|
453
|
+
}, [channel, onNewSession, report]);
|
|
454
|
+
const renameEntry = useCallback((path, title) => {
|
|
455
|
+
const next = title.trim();
|
|
456
|
+
if (next === '') {
|
|
457
|
+
report(t('home-rename-empty'), 'error');
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
void channel.renameWorkspaceAt(path, next)
|
|
461
|
+
.then((ok) => {
|
|
462
|
+
if (ok)
|
|
463
|
+
return reload();
|
|
464
|
+
report(t('home-rename-failed', { err: '' }), 'error');
|
|
465
|
+
return undefined;
|
|
466
|
+
})
|
|
467
|
+
.catch(error => report(t('home-rename-failed', { err: message(error) }), 'error'));
|
|
468
|
+
}, [channel, reload, report]);
|
|
469
|
+
const removeEntry = useCallback((path) => {
|
|
470
|
+
void channel.removeWorkspace(path)
|
|
471
|
+
.then((ok) => {
|
|
472
|
+
if (ok)
|
|
473
|
+
return reload();
|
|
474
|
+
report(t('workspace-remove-unknown', { target: path }), 'error');
|
|
475
|
+
return undefined;
|
|
476
|
+
})
|
|
477
|
+
.catch(error => report(t('workspace-remove-failed', { err: message(error) }), 'error'));
|
|
478
|
+
}, [channel, reload, report]);
|
|
479
|
+
/** Stop a parked background session; the attached one is not stoppable. */
|
|
480
|
+
const stopSession = useCallback((session) => {
|
|
481
|
+
const state = liveStateOf(session.id);
|
|
482
|
+
if (state?.current === true) {
|
|
483
|
+
report(t('supervisor-stop-current'), 'error');
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
if (state?.live !== true)
|
|
487
|
+
return;
|
|
488
|
+
void onStopSession(session.id)
|
|
489
|
+
.then((stopped) => {
|
|
490
|
+
report(stopped ? t('supervisor-stopped', { name: session.title.text }) : t('supervisor-stop-failed'), stopped ? 'info' : 'error');
|
|
491
|
+
})
|
|
492
|
+
.catch((error) => report(t('supervisor-stop-failed') + ` · ${message(error)}`, 'error'));
|
|
493
|
+
}, [liveStateOf, onStopSession, report]);
|
|
494
|
+
const closeMenu = useCallback(() => {
|
|
495
|
+
menuRef.current = undefined;
|
|
496
|
+
setMenu(undefined);
|
|
497
|
+
}, []);
|
|
498
|
+
const activateMenu = useCallback((entry, item) => {
|
|
499
|
+
closeMenu();
|
|
500
|
+
const action = MENU_ACTIONS[item] ?? 'edit';
|
|
501
|
+
if (action === 'edit')
|
|
502
|
+
selectEntry(entry);
|
|
503
|
+
else if (action === 'new')
|
|
504
|
+
newSessionIn(entry);
|
|
505
|
+
// The fallback group is not a registration, so there is no ledger row to
|
|
506
|
+
// rename or drop: those two actions would either fail or (worse) try to
|
|
507
|
+
// mutate a record that does not exist.
|
|
508
|
+
else if (entry.from === 'unregistered')
|
|
509
|
+
return;
|
|
510
|
+
else if (action === 'rename')
|
|
511
|
+
setRename({ path: entry.path, draft: entry.title });
|
|
512
|
+
else
|
|
513
|
+
setConfirmRemove(entry.path);
|
|
514
|
+
}, [closeMenu, newSessionIn, selectEntry]);
|
|
515
|
+
const moveRail = useCallback((by) => {
|
|
516
|
+
// The rail is the ledger (plus the unregistered fallback group), so every
|
|
517
|
+
// row is a workspace and the cursor is an entry index over that list alone.
|
|
518
|
+
const total = Math.max(1, railEntries.length);
|
|
519
|
+
const next = (railRef.current + by + total) % total;
|
|
520
|
+
railRef.current = next;
|
|
521
|
+
setRailFocus(next);
|
|
522
|
+
const entry = railEntries[next];
|
|
523
|
+
if (entry !== undefined)
|
|
524
|
+
selectEntry(entry);
|
|
525
|
+
}, [railEntries, selectEntry]);
|
|
526
|
+
const moveSession = useCallback((by) => {
|
|
527
|
+
// +1: the cursor space includes the new-session card as row 0, and with an
|
|
528
|
+
// EMPTY list the card is still a row the user can stand on. Clamping the
|
|
529
|
+
// index at 0 instead would have made ↓/↑ do nothing at all there.
|
|
530
|
+
const total = visibleSessions.length + 1;
|
|
531
|
+
const next = Math.min(total - 1, Math.max(0, sessionIndex + by));
|
|
532
|
+
// Landing on the card CLEARS the id: leaving the last session's id in place
|
|
533
|
+
// made the derived index resolve back to that session's row, which is what
|
|
534
|
+
// put `❯` on the first session while the user had selected the card.
|
|
535
|
+
const landed = sessionAt(next);
|
|
536
|
+
setFocusSessionId(landed?.id);
|
|
537
|
+
}, [visibleSessions, sessionAt, sessionIndex]);
|
|
538
|
+
/** The session under the cursor, or undefined while the card (row 0) holds it. */
|
|
539
|
+
const focusedSession = sessionAt(sessionIndex);
|
|
540
|
+
return {
|
|
541
|
+
entries,
|
|
542
|
+
sessions,
|
|
543
|
+
loading,
|
|
544
|
+
notice,
|
|
545
|
+
setNotice,
|
|
546
|
+
query,
|
|
547
|
+
setQuery,
|
|
548
|
+
holderOf,
|
|
549
|
+
listedSessions,
|
|
550
|
+
railEntries,
|
|
551
|
+
countOf,
|
|
552
|
+
railFocus,
|
|
553
|
+
setRailFocus,
|
|
554
|
+
setFocusSessionId,
|
|
555
|
+
activePane,
|
|
556
|
+
pins,
|
|
557
|
+
menu,
|
|
558
|
+
setMenu,
|
|
559
|
+
rename,
|
|
560
|
+
setRename,
|
|
561
|
+
confirmRemove,
|
|
562
|
+
setConfirmRemove,
|
|
563
|
+
railRef,
|
|
564
|
+
menuRef,
|
|
565
|
+
queryRef,
|
|
566
|
+
now,
|
|
567
|
+
reload,
|
|
568
|
+
selected,
|
|
569
|
+
visibleSessions,
|
|
570
|
+
sessionIndex,
|
|
571
|
+
cardFocused,
|
|
572
|
+
sessionAt,
|
|
573
|
+
activateList,
|
|
574
|
+
activateRail,
|
|
575
|
+
railWidth,
|
|
576
|
+
railVisible,
|
|
577
|
+
sessionWidth,
|
|
578
|
+
railEntryCapacity,
|
|
579
|
+
sessionListHeight,
|
|
580
|
+
persistPin,
|
|
581
|
+
selectEntry,
|
|
582
|
+
openSession,
|
|
583
|
+
newSessionIn,
|
|
584
|
+
renameEntry,
|
|
585
|
+
removeEntry,
|
|
586
|
+
stopSession,
|
|
587
|
+
closeMenu,
|
|
588
|
+
activateMenu,
|
|
589
|
+
moveRail,
|
|
590
|
+
moveSession,
|
|
591
|
+
focusedSession,
|
|
592
|
+
};
|
|
593
|
+
}
|