arkaos 3.70.10 → 3.71.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.
@@ -0,0 +1,88 @@
1
+ // v3.71.0 — app-wide terminal dock UI state. Module-level singleton so
2
+ // open/maximized/height survive route navigation, and persisted to
3
+ // localStorage so the dock restores "as you left it" across a reload.
4
+ // The dock itself (TerminalDock.vue) is mounted once in the default
5
+ // layout.
6
+
7
+ const STATE_KEY = 'arka-terminal-dock'
8
+
9
+ interface DockState {
10
+ open: boolean
11
+ maximized: boolean
12
+ height: number
13
+ }
14
+
15
+ function loadState(): DockState {
16
+ const fallback: DockState = { open: false, maximized: false, height: 45 }
17
+ if (typeof localStorage === 'undefined') return fallback
18
+ try {
19
+ const s = JSON.parse(localStorage.getItem(STATE_KEY) || '{}')
20
+ return {
21
+ open: Boolean(s.open),
22
+ maximized: Boolean(s.maximized),
23
+ height: s.height >= 20 && s.height <= 95 ? s.height : 45
24
+ }
25
+ } catch {
26
+ return fallback
27
+ }
28
+ }
29
+
30
+ const _initial = loadState()
31
+ const isOpen = ref(_initial.open)
32
+ const isMaximized = ref(_initial.maximized)
33
+ const heightVh = ref(_initial.height)
34
+
35
+ function save() {
36
+ if (typeof localStorage === 'undefined') return
37
+ try {
38
+ localStorage.setItem(
39
+ STATE_KEY,
40
+ JSON.stringify({
41
+ open: isOpen.value,
42
+ maximized: isMaximized.value,
43
+ height: heightVh.value
44
+ })
45
+ )
46
+ } catch {
47
+ // quota / private mode
48
+ }
49
+ }
50
+
51
+ export function useTerminalDock() {
52
+ function open(opts?: { maximized?: boolean }) {
53
+ isOpen.value = true
54
+ if (opts?.maximized) isMaximized.value = true
55
+ save()
56
+ }
57
+
58
+ function close() {
59
+ isOpen.value = false
60
+ save()
61
+ }
62
+
63
+ function toggle() {
64
+ if (isOpen.value) close()
65
+ else open()
66
+ }
67
+
68
+ function toggleMaximize() {
69
+ isMaximized.value = !isMaximized.value
70
+ save()
71
+ }
72
+
73
+ function setHeight(vh: number) {
74
+ heightVh.value = Math.min(95, Math.max(20, Math.round(vh)))
75
+ save()
76
+ }
77
+
78
+ return {
79
+ isOpen,
80
+ isMaximized,
81
+ heightVh,
82
+ open,
83
+ close,
84
+ toggle,
85
+ toggleMaximize,
86
+ setHeight
87
+ }
88
+ }
@@ -1,4 +1,7 @@
1
1
  // PR99b v3.68.0 — single PTY session lifecycle.
2
+ // v3.71.0 — added attach(): reconnect to an EXISTING backend session
3
+ // (no POST). The WS connect triggers the server-side scrollback replay,
4
+ // so a client reattaching after a browser reload restores its view.
2
5
  //
3
6
  // Encapsulates the REST + WebSocket handshake against /api/terminal/*.
4
7
  // The composable owns no DOM and no xterm instance — it just produces
@@ -20,6 +23,7 @@ export interface TerminalSessionHandle {
20
23
  status: Ref<'idle' | 'connecting' | 'open' | 'closed' | 'error'>
21
24
  error: Ref<string | null>
22
25
  open: () => Promise<void>
26
+ attach: (sessionId: string) => Promise<void>
23
27
  sendInput: (data: string) => void
24
28
  sendResize: (cols: number, rows: number) => void
25
29
  close: () => Promise<void>
@@ -27,9 +31,9 @@ export interface TerminalSessionHandle {
27
31
  }
28
32
 
29
33
  export function useTerminalSession(
30
- apiBaseOverride?: string,
34
+ apiBaseOverride?: string
31
35
  ): TerminalSessionHandle {
32
- // PR99c v3.69.0 — apiBaseOverride lets useTerminalTabs construct
36
+ // PR99c v3.69.0 — apiBaseOverride lets the tab store construct
33
37
  // sessions from user-event handlers without re-entering Nuxt's
34
38
  // composable context.
35
39
  const apiBase = apiBaseOverride ?? useApi().apiBase
@@ -49,7 +53,7 @@ export function useTerminalSession(
49
53
  const r = await fetch(`${apiBase}/api/terminal/sessions`, {
50
54
  method: 'POST',
51
55
  headers: { 'content-type': 'application/json' },
52
- body: JSON.stringify({ cols: 120, rows: 32 }),
56
+ body: JSON.stringify({ cols: 120, rows: 32 })
53
57
  })
54
58
  if (!r.ok) {
55
59
  const body = await r.text()
@@ -58,6 +62,40 @@ export function useTerminalSession(
58
62
  return await r.json()
59
63
  }
60
64
 
65
+ async function fetchToken(): Promise<string> {
66
+ const r = await fetch(`${apiBase}/api/terminal/token`)
67
+ if (!r.ok) throw new Error(`token fetch failed: ${r.status}`)
68
+ const body = await r.json()
69
+ return String(body.token || '')
70
+ }
71
+
72
+ function connect(wsPath: string, token: string) {
73
+ ws = new WebSocket(wsUrl(wsPath, token))
74
+ ws.binaryType = 'arraybuffer'
75
+ ws.onopen = () => {
76
+ status.value = 'open'
77
+ }
78
+ ws.onmessage = (ev) => {
79
+ if (ev.data instanceof ArrayBuffer) {
80
+ const chunk = new Uint8Array(ev.data)
81
+ for (const cb of listeners) cb(chunk)
82
+ } else if (typeof ev.data === 'string') {
83
+ const enc = new TextEncoder().encode(ev.data)
84
+ for (const cb of listeners) cb(enc)
85
+ }
86
+ }
87
+ ws.onerror = () => {
88
+ status.value = 'error'
89
+ error.value = 'websocket error'
90
+ }
91
+ ws.onclose = (ev) => {
92
+ status.value = 'closed'
93
+ if (ev.code !== 1000 && ev.code !== 1005) {
94
+ error.value = `closed (${ev.code}) ${ev.reason || ''}`.trim()
95
+ }
96
+ }
97
+ }
98
+
61
99
  async function open() {
62
100
  if (status.value === 'open' || status.value === 'connecting') return
63
101
  status.value = 'connecting'
@@ -65,30 +103,30 @@ export function useTerminalSession(
65
103
  try {
66
104
  const m = await createSession()
67
105
  meta.value = m
68
- ws = new WebSocket(wsUrl(m.ws_path, m.token))
69
- ws.binaryType = 'arraybuffer'
70
- ws.onopen = () => {
71
- status.value = 'open'
72
- }
73
- ws.onmessage = (ev) => {
74
- if (ev.data instanceof ArrayBuffer) {
75
- const chunk = new Uint8Array(ev.data)
76
- for (const cb of listeners) cb(chunk)
77
- } else if (typeof ev.data === 'string') {
78
- const enc = new TextEncoder().encode(ev.data)
79
- for (const cb of listeners) cb(enc)
80
- }
81
- }
82
- ws.onerror = () => {
83
- status.value = 'error'
84
- error.value = 'websocket error'
85
- }
86
- ws.onclose = (ev) => {
87
- status.value = 'closed'
88
- if (ev.code !== 1000 && ev.code !== 1005) {
89
- error.value = `closed (${ev.code}) ${ev.reason || ''}`.trim()
90
- }
106
+ connect(m.ws_path, m.token)
107
+ } catch (e) {
108
+ status.value = 'error'
109
+ error.value = e instanceof Error ? e.message : String(e)
110
+ }
111
+ }
112
+
113
+ async function attach(sessionId: string) {
114
+ if (status.value === 'open' || status.value === 'connecting') return
115
+ status.value = 'connecting'
116
+ error.value = null
117
+ try {
118
+ const token = await fetchToken()
119
+ const wsPath = `/ws/terminal/${sessionId}`
120
+ meta.value = {
121
+ session_id: sessionId,
122
+ shell: '',
123
+ cwd: '',
124
+ token,
125
+ ws_path: wsPath,
126
+ max_sessions: 0,
127
+ active_count: 0
91
128
  }
129
+ connect(wsPath, token)
92
130
  } catch (e) {
93
131
  status.value = 'error'
94
132
  error.value = e instanceof Error ? e.message : String(e)
@@ -108,14 +146,14 @@ export function useTerminalSession(
108
146
  async function close() {
109
147
  try {
110
148
  ws?.close(1000, 'client close')
111
- } catch (_e) {
149
+ } catch {
112
150
  // ignore
113
151
  }
114
152
  const id = meta.value?.session_id
115
153
  if (id) {
116
154
  try {
117
155
  await fetch(`${apiBase}/api/terminal/sessions/${id}`, { method: 'DELETE' })
118
- } catch (_e) {
156
+ } catch {
119
157
  // ignore — best-effort cleanup; backend reaper will catch it
120
158
  }
121
159
  }
@@ -135,9 +173,10 @@ export function useTerminalSession(
135
173
  status,
136
174
  error,
137
175
  open,
176
+ attach,
138
177
  sendInput,
139
178
  sendResize,
140
179
  close,
141
- onOutput,
180
+ onOutput
142
181
  }
143
182
  }
@@ -1,88 +1,134 @@
1
- // PR99c v3.69.0 — multi-session tab manager for /terminal.
1
+ // PR99c v3.69.0 — multi-session tab manager for the terminal.
2
+ // v3.71.0 — state is now a MODULE-LEVEL singleton (not page-scoped), so
3
+ // tabs + their live PTY sessions survive route navigation. The terminal
4
+ // UI lives in the app-wide dock (TerminalDock.vue), mounted once in the
5
+ // default layout outside <NuxtPage>, so navigating never unmounts it.
6
+ // On a full browser reload the singleton is gone, so reattachOnLoad()
7
+ // reconciles persisted session IDs against the live backend sessions and
8
+ // reconnects (the WS connect replays scrollback — see PR-T1 v3.71.0).
2
9
  //
3
- // Owns an array of `useTerminalSession()` handles plus the active tab
4
- // id. Persists tab titles (not PTYs those are re-created fresh on
5
- // reload) to localStorage. Enforces an 8-tab cap (matching the backend
6
- // default).
10
+ // Live session handles (which carry refs) are kept in a NON-reactive Map,
11
+ // separate from the reactive `tabs` metadata, so Vue never deep-unwraps
12
+ // their inner refs. SSR-safe: the dashboard runs with ssr:false.
13
+
14
+ import {
15
+ useTerminalSession,
16
+ type TerminalSessionHandle
17
+ } from '~/composables/useTerminalSession'
7
18
 
8
19
  export interface TerminalTab {
9
20
  id: string
10
21
  title: string
11
- session: ReturnType<typeof useTerminalSession>
12
22
  createdAt: number
13
23
  hasActivity: boolean
14
24
  }
15
25
 
16
- const STORAGE_KEY = 'arka-terminal-tab-titles'
26
+ const STORAGE_KEY = 'arka-terminal-tabs'
17
27
  const MAX_TABS = 8
18
28
 
19
- interface PersistedTitle {
29
+ interface PersistedTab {
20
30
  id: string
21
31
  title: string
32
+ sessionId?: string
22
33
  }
23
34
 
24
- function loadPersistedTitles(): Record<string, string> {
25
- if (typeof localStorage === 'undefined') return {}
35
+ // ─── Module-level singleton state (survives navigation) ──────────────────
36
+ const tabs = ref<TerminalTab[]>([])
37
+ const activeId = ref<string | null>(null)
38
+ const sessions = new Map<string, TerminalSessionHandle>()
39
+ const stopWatchers = new Map<string, () => void>()
40
+ let _apiBase = ''
41
+ let _reattached = false
42
+
43
+ function loadPersisted(): PersistedTab[] {
44
+ if (typeof localStorage === 'undefined') return []
26
45
  try {
27
- const raw = localStorage.getItem(STORAGE_KEY)
28
- if (!raw) return {}
29
- const parsed = JSON.parse(raw) as PersistedTitle[]
30
- return Object.fromEntries(parsed.map(p => [p.id, p.title]))
46
+ const parsed = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]')
47
+ return Array.isArray(parsed) ? parsed : []
31
48
  } catch {
32
- return {}
49
+ return []
33
50
  }
34
51
  }
35
52
 
36
- function persistTitles(tabs: TerminalTab[]) {
53
+ function persist() {
37
54
  if (typeof localStorage === 'undefined') return
38
55
  try {
39
- const payload: PersistedTitle[] = tabs.map(t => ({ id: t.id, title: t.title }))
56
+ const payload: PersistedTab[] = tabs.value.map(t => ({
57
+ id: t.id,
58
+ title: t.title,
59
+ sessionId: sessions.get(t.id)?.meta.value?.session_id
60
+ }))
40
61
  localStorage.setItem(STORAGE_KEY, JSON.stringify(payload))
41
62
  } catch {
42
63
  // quota / private mode — swallow
43
64
  }
44
65
  }
45
66
 
67
+ // Light the activity dot on an inactive tab when its session emits output.
68
+ function markActivity(id: string) {
69
+ if (activeId.value === id) return
70
+ const t = tabs.value.find(t => t.id === id)
71
+ if (t) t.hasActivity = true
72
+ }
73
+
74
+ // Register a handle: persist again once its backend session_id lands (it
75
+ // arrives asynchronously after the WS connects), and flag background tabs
76
+ // that produce output. Both subscriptions are torn down in unregister().
77
+ function register(id: string, handle: TerminalSessionHandle) {
78
+ sessions.set(id, handle)
79
+ const stopWatch = watch(() => handle.meta.value?.session_id, () => persist())
80
+ const stopOutput = handle.onOutput(() => markActivity(id))
81
+ stopWatchers.set(id, () => {
82
+ stopWatch()
83
+ stopOutput()
84
+ })
85
+ }
86
+
87
+ function unregister(id: string) {
88
+ stopWatchers.get(id)?.()
89
+ stopWatchers.delete(id)
90
+ sessions.delete(id)
91
+ }
92
+
46
93
  export function useTerminalTabs() {
47
- const { apiBase } = useApi()
48
- const tabs = ref<TerminalTab[]>([])
49
- const activeId = ref<string | null>(null)
94
+ if (!_apiBase) _apiBase = useApi().apiBase
50
95
  const capReached = computed(() => tabs.value.length >= MAX_TABS)
51
96
 
52
97
  function makeId(): string {
53
98
  return Math.random().toString(36).slice(2, 10)
54
99
  }
55
100
 
101
+ function sessionFor(id: string): TerminalSessionHandle | undefined {
102
+ return sessions.get(id)
103
+ }
104
+
56
105
  function newTab(): TerminalTab | null {
57
106
  if (tabs.value.length >= MAX_TABS) return null
58
107
  const id = makeId()
59
- const persistedTitles = loadPersistedTitles()
60
- const titleFromStorage = persistedTitles[id]
108
+ register(id, useTerminalSession(_apiBase))
61
109
  const tab: TerminalTab = {
62
110
  id,
63
- title: titleFromStorage || `Session ${tabs.value.length + 1}`,
64
- session: useTerminalSession(apiBase),
111
+ title: `Session ${tabs.value.length + 1}`,
65
112
  createdAt: Date.now(),
66
- hasActivity: false,
113
+ hasActivity: false
67
114
  }
68
115
  tabs.value.push(tab)
69
116
  activeId.value = id
70
- persistTitles(tabs.value)
117
+ persist()
71
118
  return tab
72
119
  }
73
120
 
74
121
  async function closeTab(id: string) {
75
122
  const idx = tabs.value.findIndex(t => t.id === id)
76
123
  if (idx < 0) return
77
- const tab = tabs.value[idx]
78
- if (!tab) return
79
- await tab.session.close()
124
+ await sessions.get(id)?.close()
125
+ unregister(id)
80
126
  tabs.value.splice(idx, 1)
81
127
  if (activeId.value === id) {
82
128
  const next = tabs.value[Math.min(idx, tabs.value.length - 1)]
83
129
  activeId.value = next ? next.id : null
84
130
  }
85
- persistTitles(tabs.value)
131
+ persist()
86
132
  }
87
133
 
88
134
  function switchTab(id: string) {
@@ -96,35 +142,64 @@ export function useTerminalTabs() {
96
142
  const t = tabs.value.find(t => t.id === id)
97
143
  if (!t) return
98
144
  t.title = title.trim() || t.title
99
- persistTitles(tabs.value)
100
- }
101
-
102
- function markActivity(id: string) {
103
- if (activeId.value === id) return
104
- const t = tabs.value.find(t => t.id === id)
105
- if (t) t.hasActivity = true
145
+ persist()
106
146
  }
107
147
 
108
- async function closeAll() {
109
- await Promise.all(tabs.value.map(t => t.session.close()))
110
- tabs.value = []
111
- activeId.value = null
112
- persistTitles([])
148
+ // After a full browser reload the singleton is empty. Reconcile the
149
+ // persisted session IDs against the live backend sessions and reattach
150
+ // the survivors; the WS connect replays their scrollback. Dead IDs are
151
+ // dropped. Runs at most once per page load.
152
+ async function reattachOnLoad() {
153
+ if (_reattached) return
154
+ _reattached = true
155
+ const persisted = loadPersisted()
156
+ if (persisted.length === 0) return
157
+ const alive = new Set<string>()
158
+ try {
159
+ const r = await fetch(`${_apiBase}/api/terminal/sessions`)
160
+ if (!r.ok) return
161
+ const data = await r.json()
162
+ for (const s of data.sessions || []) alive.add(s.session_id)
163
+ } catch {
164
+ return
165
+ }
166
+ const restore = persisted
167
+ .filter(p => p.sessionId && alive.has(p.sessionId))
168
+ .slice(0, MAX_TABS)
169
+ for (const p of restore) {
170
+ const handle = useTerminalSession(_apiBase)
171
+ register(p.id, handle)
172
+ tabs.value.push({
173
+ id: p.id,
174
+ title: p.title,
175
+ createdAt: Date.now(),
176
+ hasActivity: false
177
+ })
178
+ // Sets status to 'connecting' synchronously, so Terminal.vue's
179
+ // auto-open() no-ops and we reuse the live PTY instead of spawning.
180
+ void handle.attach(p.sessionId as string)
181
+ }
182
+ if (tabs.value.length > 0 && !activeId.value) {
183
+ activeId.value = tabs.value[0]!.id
184
+ }
185
+ persist()
113
186
  }
114
187
 
115
- const activeTab = computed(() => tabs.value.find(t => t.id === activeId.value) ?? null)
188
+ const activeSession = computed(() =>
189
+ activeId.value ? sessions.get(activeId.value) ?? null : null
190
+ )
116
191
 
117
192
  return {
118
193
  tabs,
119
194
  activeId,
120
- activeTab,
195
+ activeSession,
121
196
  capReached,
122
197
  maxTabs: MAX_TABS,
198
+ sessionFor,
123
199
  newTab,
124
200
  closeTab,
125
201
  switchTab,
126
202
  renameTab,
127
- markActivity,
128
- closeAll,
203
+ reattachOnLoad
129
204
  }
130
205
  }
@@ -176,5 +176,11 @@ const links = [[{
176
176
  <KeyboardShortcutsHelp />
177
177
  <GlobalSearch />
178
178
  <OnboardingTour />
179
+ <!-- v3.71.0 — app-wide terminal dock. Mounted once here (outside
180
+ <NuxtPage>) so PTY sessions survive route navigation. Client-only
181
+ because xterm.js + WebSocket have no SSR. -->
182
+ <ClientOnly>
183
+ <TerminalDock />
184
+ </ClientOnly>
179
185
  </UDashboardGroup>
180
186
  </template>