cli-jaw 2.17.30 → 2.17.35
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 +2 -0
- package/dist/src/agent/codex-app-client.js +69 -1
- package/dist/src/agent/codex-app-client.js.map +1 -1
- package/dist/src/agent/cursor-runtime.js +9 -0
- package/dist/src/agent/cursor-runtime.js.map +1 -1
- package/dist/src/agent/pi-runtime.js +1 -1
- package/dist/src/agent/pi-runtime.js.map +1 -1
- package/dist/src/agent/prompt-context.js +39 -0
- package/dist/src/agent/prompt-context.js.map +1 -1
- package/dist/src/agent/spawn.js +138 -20
- package/dist/src/agent/spawn.js.map +1 -1
- package/dist/src/browser/web-ai/capability-observation-presets.js +4 -0
- package/dist/src/browser/web-ai/capability-observation-presets.js.map +1 -1
- package/dist/src/browser/web-ai/chatgpt-model.js +9 -5
- package/dist/src/browser/web-ai/chatgpt-model.js.map +1 -1
- package/dist/src/browser/web-ai/grok-model.js +18 -5
- package/dist/src/browser/web-ai/grok-model.js.map +1 -1
- package/dist/src/cli/claude-models.js +6 -0
- package/dist/src/cli/claude-models.js.map +1 -1
- package/dist/src/cli/handlers-runtime.js +29 -4
- package/dist/src/cli/handlers-runtime.js.map +1 -1
- package/dist/src/cli/registry.js +56 -15
- package/dist/src/cli/registry.js.map +1 -1
- package/dist/src/code-mode/model-options.js +4 -4
- package/dist/src/code-mode/model-options.js.map +1 -1
- package/dist/src/core/config.js +1 -1
- package/dist/src/core/config.js.map +1 -1
- package/dist/src/core/db.js +19 -0
- package/dist/src/core/db.js.map +1 -1
- package/dist/src/core/runtime-settings.js +11 -2
- package/dist/src/core/runtime-settings.js.map +1 -1
- package/dist/src/orchestrator/attestation.js +66 -0
- package/dist/src/orchestrator/attestation.js.map +1 -1
- package/dist/src/orchestrator/gateway.js +11 -3
- package/dist/src/orchestrator/gateway.js.map +1 -1
- package/dist/src/orchestrator/pipeline.js +5 -0
- package/dist/src/orchestrator/pipeline.js.map +1 -1
- package/dist/src/routes/command.js +4 -1
- package/dist/src/routes/command.js.map +1 -1
- package/dist/src/routes/orchestrate.js +14 -2
- package/dist/src/routes/orchestrate.js.map +1 -1
- package/dist/src/slack/commands.js +1 -0
- package/dist/src/slack/commands.js.map +1 -1
- package/dist/src/telegram/bot.js +3 -2
- package/dist/src/telegram/bot.js.map +1 -1
- package/package.json +5 -1
- package/public/dist/assets/{Agent-BjxJRsKG.js → Agent-kyyjcnqC.js} +1 -1
- package/public/dist/assets/BrowserPanel-0b3deLGR.js +3 -0
- package/public/dist/assets/BrowserPanel-D7SnTts8.css +1 -0
- package/public/dist/assets/{Heartbeat-Ca8DmJcw.js → Heartbeat-CD1R95Kv.js} +1 -1
- package/public/dist/assets/{Memory-BY88ur_h.js → Memory-CpZIdE1o.js} +1 -1
- package/public/dist/assets/{ModelProvider-mtAtYLmW.js → ModelProvider-tL42E5dy.js} +1 -1
- package/public/dist/assets/agent-meta-KPnVam9G.js +1 -0
- package/public/dist/assets/{app-CYayckBa.js → app-BvRV-aEt.js} +4 -4
- package/public/dist/assets/bgtask-badge-DLqpImpn.js +1 -0
- package/public/dist/assets/{bgtask-badge-CT6s6rND.js → bgtask-badge-FiEB3FJm.js} +1 -1
- package/public/dist/assets/constants-BfYYzHNr.js +1 -0
- package/public/dist/assets/{employees-DCju7V1x.js → employees-86F_D_Ce.js} +1 -1
- package/public/dist/assets/iframe-renderer-C_swzquc.js +1 -0
- package/public/dist/assets/{iframe-renderer-LZp7Nqm1.js → iframe-renderer-eozJpgjk.js} +2 -2
- package/public/dist/assets/manager-BnmWx9xL.js +13 -0
- package/public/dist/assets/manager-Cmu0aKPb.css +1 -0
- package/public/dist/assets/{memory-V5caYbpS.js → memory-DX7U3WV_.js} +1 -1
- package/public/dist/assets/memory-DfPbqPTD.js +1 -0
- package/public/dist/assets/{message-history-Bp_sUDSx.js → message-history-B_fIGorC.js} +1 -1
- package/public/dist/assets/message-history-BhSsLjis.js +1 -0
- package/public/dist/assets/{render-Cd4frZ2U.js → render-fKAuISeM.js} +1 -1
- package/public/dist/assets/settings-BNqSR9Zr.js +1 -0
- package/public/dist/assets/{settings-a0blK1fr.js → settings-CH6exiZf.js} +1 -1
- package/public/dist/assets/{settings-core-C5qo8giT.js → settings-core-DqKyyxbQ.js} +1 -1
- package/public/dist/assets/settings-core-x7CmBZq1.js +1 -0
- package/public/dist/assets/{sidebar-FbI4udzs.js → sidebar-qyLy9Og8.js} +3 -3
- package/public/dist/assets/skills-C7Yi3LIq.js +1 -0
- package/public/dist/assets/{skills-DeG9lWF2.js → skills-DAhOtbfP.js} +1 -1
- package/public/dist/assets/{slash-commands-FoQuZXga.js → slash-commands-BYH6YVS4.js} +1 -1
- package/public/dist/assets/slash-commands-DEbJk3sj.js +1 -0
- package/public/dist/assets/{trace-drawer-CWs8ydMO.js → trace-drawer-nyAJ3Bm_.js} +1 -1
- package/public/dist/assets/{ui-Cd8etFwN.js → ui-C62w6bJS.js} +1 -1
- package/public/dist/assets/ui-CBErZPBp.js +1 -0
- package/public/dist/index.html +1 -1
- package/public/dist/manager/index.html +2 -2
- package/public/js/constants.ts +22 -5
- package/public/js/features/settings-types.ts +5 -0
- package/public/manager/src/App.tsx +1 -1
- package/public/manager/src/AppChrome.tsx +18 -3
- package/public/manager/src/SidebarRailRouter.tsx +47 -1
- package/public/manager/src/browser-panel/BrowserPanel.tsx +151 -57
- package/public/manager/src/browser-panel/browser-address-bar.tsx +53 -0
- package/public/manager/src/browser-panel/browser-address-state.ts +41 -0
- package/public/manager/src/browser-panel/browser-favicon.ts +18 -0
- package/public/manager/src/browser-panel/browser-history-store.ts +80 -0
- package/public/manager/src/browser-panel/browser-panel.css +141 -24
- package/public/manager/src/components/CommandBar.tsx +17 -5
- package/public/manager/src/components/InstanceGroups.tsx +143 -23
- package/public/manager/src/components/InstanceNavigator.tsx +55 -2
- package/public/manager/src/components/InstanceRow.tsx +51 -3
- package/public/manager/src/components/SidebarRail.tsx +10 -10
- package/public/manager/src/components/SidebarResizeHandle.tsx +22 -0
- package/public/manager/src/components/WorkspaceLayout.tsx +22 -8
- package/public/manager/src/components/instance-row-status.ts +48 -0
- package/public/manager/src/components/sidebar-keyboard.ts +129 -0
- package/public/manager/src/dashboard-settings/DashboardSettingsWorkspace.tsx +42 -0
- package/public/manager/src/help/helpContent.tsx +1 -0
- package/public/manager/src/hooks/useSidebarGroupCollapse.ts +46 -0
- package/public/manager/src/hooks/useSidebarWidth.ts +88 -0
- package/public/manager/src/hooks/useWindowFullscreen.ts +23 -0
- package/public/manager/src/manager-components.css +206 -31
- package/public/manager/src/manager-layout.css +30 -19
- package/public/manager/src/manager-p0-1-1.css +72 -36
- package/public/manager/src/manager-polish.css +6 -45
- package/public/manager/src/manager-shortcut-runner.ts +37 -1
- package/public/manager/src/manager-shortcuts.ts +21 -0
- package/public/manager/src/manager-tokens.css +190 -127
- package/public/manager/src/panels/PanelResizer.tsx +18 -2
- package/public/manager/src/panels/desktop-bridge.ts +12 -1
- package/public/manager/src/panels/panels.css +32 -32
- package/public/manager/src/settings/pages/Agent.tsx +26 -0
- package/public/manager/src/settings/pages/components/agent/agent-meta.ts +6 -5
- package/public/manager/src/styles.css +15 -7
- package/public/manager/src/types.ts +5 -1
- package/public/dist/assets/BrowserPanel-CsYKexNf.js +0 -3
- package/public/dist/assets/BrowserPanel-DpntR8EJ.css +0 -1
- package/public/dist/assets/agent-meta-_Idad3qq.js +0 -1
- package/public/dist/assets/bgtask-badge-CidVvjWb.js +0 -1
- package/public/dist/assets/constants-DK-21dMG.js +0 -1
- package/public/dist/assets/iframe-renderer-aFXUtTXX.js +0 -1
- package/public/dist/assets/manager-CwqrZ2MH.js +0 -13
- package/public/dist/assets/manager-LkGs6qEJ.css +0 -1
- package/public/dist/assets/memory-BcZdf_oz.js +0 -1
- package/public/dist/assets/message-history-DrEPUdTy.js +0 -1
- package/public/dist/assets/settings-core-Cm-CmVLA.js +0 -1
- package/public/dist/assets/settings-mbGEtP5B.js +0 -1
- package/public/dist/assets/skills-G8KUeHBV.js +0 -1
- package/public/dist/assets/slash-commands-BYv3yboz.js +0 -1
- package/public/dist/assets/ui-yrn3s72A.js +0 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { useState } from 'react';
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
2
|
import type { FormEvent, MouseEvent } from 'react';
|
|
3
3
|
import type { DashboardInstance, DashboardLifecycleAction, DashboardProfile } from '../types';
|
|
4
|
+
import { composeInstanceRowTitle, formatWorkingDurationLabel, resolveInstanceRowStatus } from './instance-row-status';
|
|
4
5
|
|
|
5
6
|
type InstanceRowProps = {
|
|
6
7
|
instance: DashboardInstance;
|
|
@@ -28,6 +29,7 @@ type InstanceRowProps = {
|
|
|
28
29
|
onMarkActivitySeen: (port: number) => void;
|
|
29
30
|
onInstanceLabelSave: (port: number, label: string | null) => Promise<void>;
|
|
30
31
|
onLifecycle: (action: DashboardLifecycleAction, instance: DashboardInstance) => void;
|
|
32
|
+
jumpHint?: string | null;
|
|
31
33
|
};
|
|
32
34
|
|
|
33
35
|
const TRANSITION_LABELS: Record<DashboardLifecycleAction, string> = {
|
|
@@ -63,6 +65,21 @@ const ChevronIcon = ({ open }: { open: boolean }) => (
|
|
|
63
65
|
</svg>
|
|
64
66
|
);
|
|
65
67
|
|
|
68
|
+
function WorkingDuration(props: { startedAtMs: number | null }) {
|
|
69
|
+
const [, setTick] = useState(0);
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
if (props.startedAtMs == null) return undefined;
|
|
72
|
+
const id = window.setInterval(() => setTick(tick => tick + 1), 1000);
|
|
73
|
+
return () => window.clearInterval(id);
|
|
74
|
+
}, [props.startedAtMs]);
|
|
75
|
+
if (props.startedAtMs == null) return null;
|
|
76
|
+
return (
|
|
77
|
+
<span className="instance-row-working-duration">
|
|
78
|
+
{formatWorkingDurationLabel(Date.now() - props.startedAtMs)}
|
|
79
|
+
</span>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
66
83
|
export function InstanceRow(props: InstanceRowProps) {
|
|
67
84
|
const lifecycle = props.instance.lifecycle;
|
|
68
85
|
const reason = lifecycle?.reason || props.instance.healthReason || 'ok';
|
|
@@ -76,7 +93,24 @@ export function InstanceRow(props: InstanceRowProps) {
|
|
|
76
93
|
}
|
|
77
94
|
|
|
78
95
|
const transitionLabel = props.transitioning ? TRANSITION_LABELS[props.transitioning] : null;
|
|
79
|
-
const
|
|
96
|
+
const rowStatus = resolveInstanceRowStatus(props.instance, {
|
|
97
|
+
busy: Boolean(props.agentBusy),
|
|
98
|
+
transitioning: props.transitioning || null,
|
|
99
|
+
});
|
|
100
|
+
const startedAtRef = useRef<number | null>(null);
|
|
101
|
+
if (rowStatus === 'working') {
|
|
102
|
+
if (startedAtRef.current == null) startedAtRef.current = Date.now();
|
|
103
|
+
} else {
|
|
104
|
+
startedAtRef.current = null;
|
|
105
|
+
}
|
|
106
|
+
const statusLabel = rowStatus === 'transitioning' && transitionLabel
|
|
107
|
+
? transitionLabel
|
|
108
|
+
: rowStatus === 'working' ? 'Working'
|
|
109
|
+
: rowStatus === 'offline' ? 'Offline'
|
|
110
|
+
: rowStatus === 'attention' ? 'Attention'
|
|
111
|
+
: 'Online';
|
|
112
|
+
const hideStatusLine = props.density === 'compact' || props.density === 'rail';
|
|
113
|
+
const dotClass = `${statusClass(props.instance.status)}${transitionLabel ? ' is-transitioning' : ''}${props.agentBusy ? ' is-busy' : ''} is-${rowStatus}`;
|
|
80
114
|
const primaryLabel = props.instance.label || props.profile?.label || props.label;
|
|
81
115
|
async function submitLabel(event: FormEvent<HTMLFormElement>): Promise<void> {
|
|
82
116
|
event.preventDefault();
|
|
@@ -94,10 +128,15 @@ export function InstanceRow(props: InstanceRowProps) {
|
|
|
94
128
|
}
|
|
95
129
|
|
|
96
130
|
return (
|
|
97
|
-
<article
|
|
131
|
+
<article
|
|
132
|
+
className={`instance-row density-${props.density || 'comfortable'} priority-${props.priority || 'normal'} ${props.selected ? 'is-selected' : ''}${transitionLabel ? ' is-transitioning-row' : ''} is-${rowStatus}`}
|
|
133
|
+
title={composeInstanceRowTitle(props.instance)}
|
|
134
|
+
aria-current={props.selected ? 'true' : undefined}
|
|
135
|
+
>
|
|
98
136
|
<button
|
|
99
137
|
className="instance-row-select"
|
|
100
138
|
type="button"
|
|
139
|
+
data-instance-port={props.instance.port}
|
|
101
140
|
aria-pressed={props.selected}
|
|
102
141
|
onClick={() => {
|
|
103
142
|
props.onSelect(props.instance);
|
|
@@ -106,6 +145,14 @@ export function InstanceRow(props: InstanceRowProps) {
|
|
|
106
145
|
<div className="instance-row-main">
|
|
107
146
|
<span className={dotClass} aria-label={props.instance.status} />
|
|
108
147
|
<div className="instance-row-title">
|
|
148
|
+
{!hideStatusLine ? (
|
|
149
|
+
<div className="instance-row-status-line" data-status={rowStatus}>
|
|
150
|
+
<span className={`instance-row-status-pill is-${rowStatus} health-${props.instance.status}`}>
|
|
151
|
+
{statusLabel}
|
|
152
|
+
</span>
|
|
153
|
+
{rowStatus === 'working' ? <WorkingDuration startedAtMs={startedAtRef.current} /> : null}
|
|
154
|
+
</div>
|
|
155
|
+
) : null}
|
|
109
156
|
<div className="instance-row-title-line">
|
|
110
157
|
<strong>{props.instance.favorite ? `Pinned ${primaryLabel}` : primaryLabel}</strong>
|
|
111
158
|
{props.activityUnreadCount ? (
|
|
@@ -219,6 +266,7 @@ export function InstanceRow(props: InstanceRowProps) {
|
|
|
219
266
|
</svg>
|
|
220
267
|
</button>
|
|
221
268
|
) : null}
|
|
269
|
+
{props.jumpHint ? <span className="instance-jump-hint" aria-hidden="true">{props.jumpHint}</span> : null}
|
|
222
270
|
{props.showSelectedActions !== false && (
|
|
223
271
|
<div className="instance-actions">
|
|
224
272
|
<button
|
|
@@ -18,11 +18,11 @@ function ChevronIcon({ direction }: { direction: 'left' | 'right' }) {
|
|
|
18
18
|
<svg
|
|
19
19
|
className="rail-collapse-chevron"
|
|
20
20
|
viewBox="0 0 16 20"
|
|
21
|
-
width="
|
|
22
|
-
height="
|
|
21
|
+
width="18"
|
|
22
|
+
height="18"
|
|
23
23
|
fill="none"
|
|
24
24
|
stroke="currentColor"
|
|
25
|
-
strokeWidth="1.
|
|
25
|
+
strokeWidth="1.5"
|
|
26
26
|
strokeLinecap="round"
|
|
27
27
|
strokeLinejoin="round"
|
|
28
28
|
aria-hidden="true"
|
|
@@ -35,7 +35,7 @@ function ChevronIcon({ direction }: { direction: 'left' | 'right' }) {
|
|
|
35
35
|
|
|
36
36
|
function MonitorIcon() {
|
|
37
37
|
return (
|
|
38
|
-
<svg viewBox="0 0 20 20" width="
|
|
38
|
+
<svg viewBox="0 0 20 20" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" focusable="false">
|
|
39
39
|
<rect x="3" y="4" width="14" height="10" rx="1.5" />
|
|
40
40
|
<path d="M8 17h4M10 14v3" />
|
|
41
41
|
</svg>
|
|
@@ -44,7 +44,7 @@ function MonitorIcon() {
|
|
|
44
44
|
|
|
45
45
|
function NoteIcon() {
|
|
46
46
|
return (
|
|
47
|
-
<svg viewBox="0 0 20 20" width="
|
|
47
|
+
<svg viewBox="0 0 20 20" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" focusable="false">
|
|
48
48
|
<path d="M6 3h6l3 3v11H6z" />
|
|
49
49
|
<path d="M12 3v4h3M8 10h5M8 13h5" />
|
|
50
50
|
</svg>
|
|
@@ -53,7 +53,7 @@ function NoteIcon() {
|
|
|
53
53
|
|
|
54
54
|
function BoardIcon() {
|
|
55
55
|
return (
|
|
56
|
-
<svg viewBox="0 0 20 20" width="
|
|
56
|
+
<svg viewBox="0 0 20 20" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" focusable="false">
|
|
57
57
|
<rect x="3" y="4" width="14" height="12" rx="1.5" />
|
|
58
58
|
<path d="M7 4v12M13 4v12" />
|
|
59
59
|
</svg>
|
|
@@ -62,7 +62,7 @@ function BoardIcon() {
|
|
|
62
62
|
|
|
63
63
|
function ScheduleIcon() {
|
|
64
64
|
return (
|
|
65
|
-
<svg viewBox="0 0 20 20" width="
|
|
65
|
+
<svg viewBox="0 0 20 20" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" focusable="false">
|
|
66
66
|
<rect x="3" y="5" width="14" height="12" rx="1.5" />
|
|
67
67
|
<path d="M3 9h14M7 3v4M13 3v4" />
|
|
68
68
|
</svg>
|
|
@@ -71,7 +71,7 @@ function ScheduleIcon() {
|
|
|
71
71
|
|
|
72
72
|
function ReminderIcon() {
|
|
73
73
|
return (
|
|
74
|
-
<svg viewBox="0 0 20 20" width="
|
|
74
|
+
<svg viewBox="0 0 20 20" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" focusable="false">
|
|
75
75
|
<path d="M5 4h10v13H5z" />
|
|
76
76
|
<path d="M8 2v4M12 2v4M8 10h4M8 13h3" />
|
|
77
77
|
</svg>
|
|
@@ -80,7 +80,7 @@ function ReminderIcon() {
|
|
|
80
80
|
|
|
81
81
|
function SettingsIcon() {
|
|
82
82
|
return (
|
|
83
|
-
<svg viewBox="0 0 24 24" width="
|
|
83
|
+
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" focusable="false">
|
|
84
84
|
<path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915" />
|
|
85
85
|
<circle cx="12" cy="12" r="3" />
|
|
86
86
|
</svg>
|
|
@@ -89,7 +89,7 @@ function SettingsIcon() {
|
|
|
89
89
|
|
|
90
90
|
function HelpIcon() {
|
|
91
91
|
return (
|
|
92
|
-
<svg viewBox="0 0 20 20" width="
|
|
92
|
+
<svg viewBox="0 0 20 20" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" focusable="false">
|
|
93
93
|
<circle cx="10" cy="10" r="7" />
|
|
94
94
|
<path d="M7.6 7.6a2.4 2.4 0 0 1 4.8 0c0 1.4-1.6 1.7-2.2 2.5-.3.4-.3.8-.3 1.2" />
|
|
95
95
|
<circle cx="10" cy="14.2" r="0.6" fill="currentColor" stroke="none" />
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { PanelResizer } from '../panels/PanelResizer';
|
|
2
|
+
|
|
3
|
+
type SidebarResizeHandleProps = {
|
|
4
|
+
width: number;
|
|
5
|
+
onDelta: (delta: number) => void;
|
|
6
|
+
onEnd: () => void;
|
|
7
|
+
onDoubleClick: () => void;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function SidebarResizeHandle(props: SidebarResizeHandleProps) {
|
|
11
|
+
return (
|
|
12
|
+
<PanelResizer
|
|
13
|
+
direction="horizontal"
|
|
14
|
+
onDelta={props.onDelta}
|
|
15
|
+
onEnd={props.onEnd}
|
|
16
|
+
onDoubleClick={props.onDoubleClick}
|
|
17
|
+
className="sidebar-resize-handle"
|
|
18
|
+
ariaLabel="Resize sidebar"
|
|
19
|
+
ariaValueNow={props.width}
|
|
20
|
+
/>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { useEffect, useState, type CSSProperties, type ReactNode } from 'react';
|
|
2
|
+
import { SidebarResizeHandle } from './SidebarResizeHandle';
|
|
3
|
+
import { SIDEBAR_COLLAPSED_WIDTH } from '../hooks/useSidebarWidth';
|
|
2
4
|
|
|
3
5
|
type WorkspaceLayoutProps = {
|
|
4
6
|
navigator: ReactNode;
|
|
@@ -19,11 +21,15 @@ type WorkspaceLayoutProps = {
|
|
|
19
21
|
bottomPanelContent?: ReactNode;
|
|
20
22
|
bottomPanelHeight?: number;
|
|
21
23
|
bottomPanelOpen?: boolean;
|
|
24
|
+
sidebarWidth: number;
|
|
25
|
+
onSidebarWidthDelta: (delta: number) => void;
|
|
26
|
+
onSidebarWidthEnd: () => void;
|
|
27
|
+
onSidebarWidthReset: () => void;
|
|
22
28
|
};
|
|
23
29
|
|
|
24
30
|
type WorkspaceLayoutStyle = CSSProperties & {
|
|
25
31
|
'--activity-dock-height': string;
|
|
26
|
-
'--sidebar-width'
|
|
32
|
+
'--sidebar-width': string;
|
|
27
33
|
'--right-panel-width'?: string | undefined;
|
|
28
34
|
'--bottom-panel-height'?: string | undefined;
|
|
29
35
|
};
|
|
@@ -48,13 +54,10 @@ function useViewportWidth(): number {
|
|
|
48
54
|
return viewportWidth;
|
|
49
55
|
}
|
|
50
56
|
|
|
51
|
-
function clampRightPanelRenderWidth(width: number | undefined,
|
|
57
|
+
function clampRightPanelRenderWidth(width: number | undefined, sidebarWidth: number, viewportWidth: number): number {
|
|
52
58
|
const desired = typeof width === 'number' && Number.isFinite(width)
|
|
53
59
|
? Math.round(width)
|
|
54
60
|
: 480;
|
|
55
|
-
const sidebarWidth = sidebarCollapsed
|
|
56
|
-
? 56
|
|
57
|
-
: viewportWidth >= 1440 ? 360 : 300;
|
|
58
61
|
const maxByViewport = Math.max(
|
|
59
62
|
RIGHT_PANEL_RENDER_MIN_WIDTH,
|
|
60
63
|
viewportWidth - sidebarWidth - WORKSPACE_CENTER_MIN_WIDTH,
|
|
@@ -67,13 +70,14 @@ function clampRightPanelRenderWidth(width: number | undefined, sidebarCollapsed:
|
|
|
67
70
|
|
|
68
71
|
export function WorkspaceLayout(props: WorkspaceLayoutProps) {
|
|
69
72
|
const viewportWidth = useViewportWidth();
|
|
73
|
+
const sidebarRenderWidth = props.sidebarCollapsed ? SIDEBAR_COLLAPSED_WIDTH : props.sidebarWidth;
|
|
70
74
|
const rightPanelWidth = props.rightPanelOpen
|
|
71
|
-
? clampRightPanelRenderWidth(props.rightPanelWidth,
|
|
75
|
+
? clampRightPanelRenderWidth(props.rightPanelWidth, sidebarRenderWidth, viewportWidth)
|
|
72
76
|
: 0;
|
|
73
77
|
|
|
74
78
|
const style: WorkspaceLayoutStyle = {
|
|
75
79
|
'--activity-dock-height': `${props.inspectorHeight}px`,
|
|
76
|
-
'--sidebar-width': props.sidebarCollapsed ?
|
|
80
|
+
'--sidebar-width': props.sidebarCollapsed ? `${SIDEBAR_COLLAPSED_WIDTH}px` : `${props.sidebarWidth}px`,
|
|
77
81
|
'--right-panel-width': props.rightPanelOpen
|
|
78
82
|
? `${rightPanelWidth}px`
|
|
79
83
|
: props.sidePanel ? undefined : '0px',
|
|
@@ -97,7 +101,17 @@ export function WorkspaceLayout(props: WorkspaceLayoutProps) {
|
|
|
97
101
|
return (
|
|
98
102
|
<div className={cls} style={style}>
|
|
99
103
|
{props.drawerOpen && <div className="drawer-backdrop" onClick={props.onCloseDrawer} />}
|
|
100
|
-
<aside className="manager-sidebar" aria-label={props.navigatorLabel ?? 'Jaw instances'}>
|
|
104
|
+
<aside className="manager-sidebar" aria-label={props.navigatorLabel ?? 'Jaw instances'}>
|
|
105
|
+
{props.navigator}
|
|
106
|
+
</aside>
|
|
107
|
+
{!props.sidebarCollapsed && viewportWidth >= 1024 && (
|
|
108
|
+
<SidebarResizeHandle
|
|
109
|
+
width={props.sidebarWidth}
|
|
110
|
+
onDelta={props.onSidebarWidthDelta}
|
|
111
|
+
onEnd={props.onSidebarWidthEnd}
|
|
112
|
+
onDoubleClick={props.onSidebarWidthReset}
|
|
113
|
+
/>
|
|
114
|
+
)}
|
|
101
115
|
<section className="manager-detail" aria-label="Manager workbench">{props.workbench}</section>
|
|
102
116
|
<section className="manager-activity" aria-label="Manager inspector">{props.inspector}</section>
|
|
103
117
|
{props.sidePanel && <aside className="manager-ceo-panel" aria-label="Jaw CEO console">{props.sidePanel}</aside>}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { DashboardInstance, DashboardLifecycleAction } from '../types';
|
|
2
|
+
|
|
3
|
+
export type InstanceRowStatus =
|
|
4
|
+
| 'working'
|
|
5
|
+
| 'online'
|
|
6
|
+
| 'offline'
|
|
7
|
+
| 'attention'
|
|
8
|
+
| 'transitioning';
|
|
9
|
+
|
|
10
|
+
export function resolveInstanceRowStatus(
|
|
11
|
+
instance: Pick<DashboardInstance, 'status'>,
|
|
12
|
+
flags: { busy?: boolean; transitioning?: DashboardLifecycleAction | null } = {},
|
|
13
|
+
): InstanceRowStatus {
|
|
14
|
+
if (flags.transitioning) return 'transitioning';
|
|
15
|
+
if (flags.busy) return 'working';
|
|
16
|
+
if (instance.status === 'offline') return 'offline';
|
|
17
|
+
if (instance.status === 'online') return 'online';
|
|
18
|
+
return 'attention';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function formatWorkingDurationLabel(elapsedMs: number): string {
|
|
22
|
+
const seconds = Number.isFinite(elapsedMs) ? Math.max(0, Math.floor(elapsedMs / 1000)) : 0;
|
|
23
|
+
if (seconds < 60) return `${seconds}s`;
|
|
24
|
+
const minutes = Math.floor(seconds / 60);
|
|
25
|
+
if (minutes < 60) return `${minutes}m`;
|
|
26
|
+
return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function composeInstanceRowTitle(instance: Pick<DashboardInstance, 'port' | 'homeDisplay' | 'currentCli' | 'currentModel' | 'version'>): string {
|
|
30
|
+
return [
|
|
31
|
+
`:${instance.port}`,
|
|
32
|
+
instance.homeDisplay || 'home n/a',
|
|
33
|
+
instance.currentCli || 'cli n/a',
|
|
34
|
+
instance.currentModel || 'model n/a',
|
|
35
|
+
`v${instance.version || 'n/a'}`,
|
|
36
|
+
].join(' · ');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function comparePinnedThenLabel<T extends Pick<DashboardInstance, 'favorite' | 'label' | 'port'>>(
|
|
40
|
+
a: T,
|
|
41
|
+
b: T,
|
|
42
|
+
labelOf: (instance: T) => string,
|
|
43
|
+
): number {
|
|
44
|
+
const fav = Number(Boolean(b.favorite)) - Number(Boolean(a.favorite));
|
|
45
|
+
if (fav !== 0) return fav;
|
|
46
|
+
const byLabel = labelOf(a).localeCompare(labelOf(b), undefined, { sensitivity: 'base' });
|
|
47
|
+
return byLabel !== 0 ? byLabel : a.port - b.port;
|
|
48
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
export const INSTANCE_JUMP_HINT_SHOW_DELAY_MS = 200;
|
|
2
|
+
export const SETTLED_TAIL_INITIAL_COUNT = 10;
|
|
3
|
+
export const SETTLED_TAIL_PAGE_COUNT = 25;
|
|
4
|
+
|
|
5
|
+
export type TraverseDir = 'previous' | 'next';
|
|
6
|
+
export type InstanceJumpSelector = (port: number) => void;
|
|
7
|
+
|
|
8
|
+
export function resolveAdjacentPort(list: readonly number[], current: number | null, dir: TraverseDir): number | null {
|
|
9
|
+
if (list.length === 0) return null;
|
|
10
|
+
if (current == null) return dir === 'previous' ? (list[list.length - 1] ?? null) : (list[0] ?? null);
|
|
11
|
+
const idx = list.indexOf(current);
|
|
12
|
+
if (idx === -1) return null;
|
|
13
|
+
if (dir === 'previous') return idx > 0 ? (list[idx - 1] ?? null) : null;
|
|
14
|
+
return idx < list.length - 1 ? (list[idx + 1] ?? null) : null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function jumpInstanceIndexFromAction(action: string): number | null {
|
|
18
|
+
const match = /^jumpInstance([1-9])$/.exec(action);
|
|
19
|
+
return match ? Number(match[1]!) - 1 : null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function isSettledStatus(status: string): boolean {
|
|
23
|
+
return status === 'offline' || status === 'unknown' || status === 'timeout';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function pageSettledPorts(settledPorts: readonly number[], visibleCount: number, selectedPort: number | null): number[] {
|
|
27
|
+
const visible = settledPorts.slice(0, Math.max(0, visibleCount));
|
|
28
|
+
if (selectedPort != null && settledPorts.includes(selectedPort) && !visible.includes(selectedPort)) {
|
|
29
|
+
return [...visible, selectedPort];
|
|
30
|
+
}
|
|
31
|
+
return visible;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function createJumpHintVisibilityController(input: {
|
|
35
|
+
delayMs: number;
|
|
36
|
+
onVisibilityChange: (visible: boolean) => void;
|
|
37
|
+
setTimeoutFn?: typeof setTimeout;
|
|
38
|
+
clearTimeoutFn?: typeof clearTimeout;
|
|
39
|
+
}): { sync: (shouldShow: boolean) => void; dispose: () => void } {
|
|
40
|
+
const setTimeoutFn = input.setTimeoutFn ?? setTimeout;
|
|
41
|
+
const clearTimeoutFn = input.clearTimeoutFn ?? clearTimeout;
|
|
42
|
+
let isVisible = false;
|
|
43
|
+
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
44
|
+
const clearPending = (): void => {
|
|
45
|
+
if (timeoutId == null) return;
|
|
46
|
+
clearTimeoutFn(timeoutId);
|
|
47
|
+
timeoutId = null;
|
|
48
|
+
};
|
|
49
|
+
return {
|
|
50
|
+
sync(shouldShow: boolean): void {
|
|
51
|
+
if (!shouldShow) {
|
|
52
|
+
clearPending();
|
|
53
|
+
if (isVisible) {
|
|
54
|
+
isVisible = false;
|
|
55
|
+
input.onVisibilityChange(false);
|
|
56
|
+
}
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (isVisible || timeoutId != null) return;
|
|
60
|
+
timeoutId = setTimeoutFn(() => {
|
|
61
|
+
timeoutId = null;
|
|
62
|
+
isVisible = true;
|
|
63
|
+
input.onVisibilityChange(true);
|
|
64
|
+
}, input.delayMs);
|
|
65
|
+
},
|
|
66
|
+
dispose(): void { clearPending(); },
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let jumpHintsVisible = false;
|
|
71
|
+
const jumpHintListeners = new Set<() => void>();
|
|
72
|
+
|
|
73
|
+
export function subscribeJumpHints(cb: () => void): () => void {
|
|
74
|
+
jumpHintListeners.add(cb);
|
|
75
|
+
return () => { jumpHintListeners.delete(cb); };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function getJumpHintsVisible(): boolean {
|
|
79
|
+
return jumpHintsVisible;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function setJumpHintsVisible(next: boolean): void {
|
|
83
|
+
if (jumpHintsVisible === next) return;
|
|
84
|
+
jumpHintsVisible = next;
|
|
85
|
+
jumpHintListeners.forEach(listener => listener());
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function shouldArmJumpHint(event: Pick<KeyboardEvent, 'altKey' | 'metaKey' | 'ctrlKey' | 'shiftKey' | 'key'>): boolean {
|
|
89
|
+
return event.altKey && !event.metaKey && !event.ctrlKey && !event.shiftKey;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function readRenderedInstancePorts(root: ParentNode): number[] {
|
|
93
|
+
return Array.from(root.querySelectorAll<HTMLElement>('[data-instance-port]'))
|
|
94
|
+
.map(el => Number(el.dataset['instancePort']))
|
|
95
|
+
.filter(port => Number.isInteger(port));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function handleInstanceListKeyDown(event: KeyboardEvent, root: ParentNode, onSelectPort: (port: number) => void): void {
|
|
99
|
+
if (event.isComposing || event.keyCode === 229) return;
|
|
100
|
+
const buttons = Array.from(root.querySelectorAll<HTMLElement>('[data-instance-port]'));
|
|
101
|
+
if (buttons.length === 0) return;
|
|
102
|
+
const ports = buttons.map(el => Number(el.dataset['instancePort']));
|
|
103
|
+
const active = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
104
|
+
const currentEl = active?.closest('[data-instance-port]');
|
|
105
|
+
const currentPort = currentEl instanceof HTMLElement ? Number(currentEl.dataset['instancePort']) : null;
|
|
106
|
+
let next: number | null = null;
|
|
107
|
+
if (event.key === 'ArrowDown') next = resolveAdjacentPort(ports, currentPort, 'next');
|
|
108
|
+
else if (event.key === 'ArrowUp') next = resolveAdjacentPort(ports, currentPort, 'previous');
|
|
109
|
+
else if (event.key === 'Home') next = ports[0] ?? null;
|
|
110
|
+
else if (event.key === 'End') next = ports[ports.length - 1] ?? null;
|
|
111
|
+
else if (event.key === 'Enter' && currentPort != null) {
|
|
112
|
+
event.preventDefault();
|
|
113
|
+
onSelectPort(currentPort);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (next == null) return;
|
|
117
|
+
event.preventDefault();
|
|
118
|
+
buttons.find(el => Number(el.dataset['instancePort']) === next)?.focus();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let instanceJumpSelector: InstanceJumpSelector | null = null;
|
|
122
|
+
|
|
123
|
+
export function registerInstanceJumpSelector(fn: InstanceJumpSelector | null): void {
|
|
124
|
+
instanceJumpSelector = fn;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function getInstanceJumpSelector(): InstanceJumpSelector | null {
|
|
128
|
+
return instanceJumpSelector;
|
|
129
|
+
}
|
|
@@ -100,6 +100,16 @@ const COPY = {
|
|
|
100
100
|
scope: '단축키',
|
|
101
101
|
description: '현재 필터 목록에서 다음 인스턴스를 선택합니다.',
|
|
102
102
|
},
|
|
103
|
+
shortcutResetSidebarWidth: {
|
|
104
|
+
label: '사이드바 폭 리셋',
|
|
105
|
+
scope: '단축키',
|
|
106
|
+
description: '기본 300으로 되돌리고 \'jaw.sidebarWidth\' 키를 지운다.',
|
|
107
|
+
},
|
|
108
|
+
shortcutJumpInstance: {
|
|
109
|
+
label: '인스턴스 점프',
|
|
110
|
+
scope: '단축키',
|
|
111
|
+
description: '렌더된 사이드바 행 1–9로 이동합니다. Meta+1–4는 탭 전환에 남아 있어 Alt를 씁니다.',
|
|
112
|
+
},
|
|
103
113
|
},
|
|
104
114
|
support: {
|
|
105
115
|
ariaLabel: '작업 제목 출처 준비 상태',
|
|
@@ -176,6 +186,16 @@ const COPY = {
|
|
|
176
186
|
scope: 'Shortcut',
|
|
177
187
|
description: 'Select the next instance in the current filtered list.',
|
|
178
188
|
},
|
|
189
|
+
shortcutResetSidebarWidth: {
|
|
190
|
+
label: 'Reset sidebar width',
|
|
191
|
+
scope: 'Shortcut',
|
|
192
|
+
description: 'Restore the default 300px width and delete the \'jaw.sidebarWidth\' key.',
|
|
193
|
+
},
|
|
194
|
+
shortcutJumpInstance: {
|
|
195
|
+
label: 'Jump to instance',
|
|
196
|
+
scope: 'Shortcut',
|
|
197
|
+
description: 'Jump to rendered sidebar rows 1–9. Meta+1–4 stay on tab switching, so these use Alt.',
|
|
198
|
+
},
|
|
179
199
|
},
|
|
180
200
|
support: {
|
|
181
201
|
ariaLabel: 'Activity title source readiness',
|
|
@@ -252,6 +272,16 @@ const COPY = {
|
|
|
252
272
|
scope: '快捷键',
|
|
253
273
|
description: '选择当前筛选列表中的下一个实例。',
|
|
254
274
|
},
|
|
275
|
+
shortcutResetSidebarWidth: {
|
|
276
|
+
label: '重置侧边栏宽度',
|
|
277
|
+
scope: '快捷键',
|
|
278
|
+
description: '恢复默认 300px 宽度并删除 \'jaw.sidebarWidth\' 键。',
|
|
279
|
+
},
|
|
280
|
+
shortcutJumpInstance: {
|
|
281
|
+
label: '跳转到实例',
|
|
282
|
+
scope: '快捷键',
|
|
283
|
+
description: '跳转到已渲染的侧边栏第 1–9 行。Meta+1–4 仍用于切换标签,因此这里使用 Alt。',
|
|
284
|
+
},
|
|
255
285
|
},
|
|
256
286
|
support: {
|
|
257
287
|
ariaLabel: '活动标题来源就绪状态',
|
|
@@ -328,6 +358,16 @@ const COPY = {
|
|
|
328
358
|
scope: 'ショートカット',
|
|
329
359
|
description: '現在のフィルタ一覧で次のインスタンスを選択します。',
|
|
330
360
|
},
|
|
361
|
+
shortcutResetSidebarWidth: {
|
|
362
|
+
label: 'サイドバー幅をリセット',
|
|
363
|
+
scope: 'ショートカット',
|
|
364
|
+
description: '既定の 300px に戻し、\'jaw.sidebarWidth\' キーを削除します。',
|
|
365
|
+
},
|
|
366
|
+
shortcutJumpInstance: {
|
|
367
|
+
label: 'インスタンスへジャンプ',
|
|
368
|
+
scope: 'ショートカット',
|
|
369
|
+
description: '描画済みサイドバー行 1–9 へ移動します。Meta+1–4 はタブ切替のままなので Alt を使います。',
|
|
370
|
+
},
|
|
331
371
|
},
|
|
332
372
|
support: {
|
|
333
373
|
ariaLabel: 'アクティビティタイトル取得元の準備状態',
|
|
@@ -445,6 +485,8 @@ function shortcutCopyKey(action: DashboardShortcutAction): keyof typeof COPY.ko.
|
|
|
445
485
|
if (action === 'focusActiveSession') return 'shortcutFocusActiveSession';
|
|
446
486
|
if (action === 'focusNotes') return 'shortcutFocusNotes';
|
|
447
487
|
if (action === 'previousInstance') return 'shortcutPreviousInstance';
|
|
488
|
+
if (action === 'resetSidebarWidth') return 'shortcutResetSidebarWidth';
|
|
489
|
+
if (action.startsWith('jumpInstance')) return 'shortcutJumpInstance';
|
|
448
490
|
return 'shortcutNextInstance';
|
|
449
491
|
}
|
|
450
492
|
|
|
@@ -148,6 +148,7 @@ const ShortcutsHelp = (
|
|
|
148
148
|
<li><kbd>{formatShortcut(DEFAULT_MANAGER_SHORTCUT_KEYMAP.focusActiveSession)}</kbd>는 선택된 인스턴스의 Preview 탭으로 이동해요.</li>
|
|
149
149
|
<li><kbd>{formatShortcut(DEFAULT_MANAGER_SHORTCUT_KEYMAP.focusNotes)}</kbd>는 Notes workspace로 이동해요.</li>
|
|
150
150
|
<li><kbd>{formatShortcut(DEFAULT_MANAGER_SHORTCUT_KEYMAP.previousInstance)}</kbd> / <kbd>{formatShortcut(DEFAULT_MANAGER_SHORTCUT_KEYMAP.nextInstance)}</kbd>는 현재 필터된 인스턴스 목록에서 이전/다음 행을 선택해요.</li>
|
|
151
|
+
<li><kbd>Alt+1</kbd> … <kbd>Alt+9</kbd>는 렌더된 사이드바 행으로 점프하고, Alt를 200ms 누르면 행 오른쪽에 점프 힌트가 떠요.</li>
|
|
151
152
|
<li><kbd>⌘/Ctrl + S</kbd>는 Notes와 Settings처럼 저장 가능한 화면에서 현재 편집 내용을 저장해요.</li>
|
|
152
153
|
<li><kbd>⌘/Ctrl + E</kbd>는 Notes 편집 모드를 순환해요.</li>
|
|
153
154
|
<li>Manager 단축키는 Dashboard settings에서 켜고 끄거나 keymap을 바꿀 수 있어요.</li>
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { useCallback, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
export const SIDEBAR_GROUP_COLLAPSED_KEY = 'jaw.sidebarGroupCollapsed';
|
|
4
|
+
|
|
5
|
+
function readMap(): Record<string, boolean> {
|
|
6
|
+
try {
|
|
7
|
+
if (typeof localStorage === 'undefined') return {};
|
|
8
|
+
const raw = localStorage.getItem(SIDEBAR_GROUP_COLLAPSED_KEY);
|
|
9
|
+
if (!raw) return {};
|
|
10
|
+
const parsed: unknown = JSON.parse(raw);
|
|
11
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
|
|
12
|
+
const next: Record<string, boolean> = {};
|
|
13
|
+
for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
|
|
14
|
+
if (typeof value === 'boolean') next[key] = value;
|
|
15
|
+
}
|
|
16
|
+
return next;
|
|
17
|
+
} catch {
|
|
18
|
+
return {};
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function writeMap(value: Record<string, boolean>): void {
|
|
23
|
+
try {
|
|
24
|
+
if (typeof localStorage === 'undefined') return;
|
|
25
|
+
localStorage.setItem(SIDEBAR_GROUP_COLLAPSED_KEY, JSON.stringify(value));
|
|
26
|
+
} catch {
|
|
27
|
+
// private mode
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function useSidebarGroupCollapse(): {
|
|
32
|
+
isCollapsed: (groupKey: string) => boolean;
|
|
33
|
+
toggle: (groupKey: string) => void;
|
|
34
|
+
toggleGroup: (groupKey: string) => void;
|
|
35
|
+
} {
|
|
36
|
+
const [collapsedByKey, setCollapsedByKey] = useState<Record<string, boolean>>(readMap);
|
|
37
|
+
const isCollapsed = useCallback((groupKey: string) => collapsedByKey[groupKey] === true, [collapsedByKey]);
|
|
38
|
+
const toggle = useCallback((groupKey: string) => {
|
|
39
|
+
setCollapsedByKey(prev => {
|
|
40
|
+
const next = { ...prev, [groupKey]: !prev[groupKey] };
|
|
41
|
+
writeMap(next);
|
|
42
|
+
return next;
|
|
43
|
+
});
|
|
44
|
+
}, []);
|
|
45
|
+
return { isCollapsed, toggle, toggleGroup: toggle };
|
|
46
|
+
}
|