loadtoagent 1.3.1 → 1.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.ko.md +11 -9
  2. package/docs/PROVIDER-CONTRACTS.md +8 -6
  3. package/main.js +25 -3
  4. package/package.json +1 -1
  5. package/preload.js +7 -2
  6. package/renderer/app-dashboard.js +18 -12
  7. package/renderer/app-graph-view.js +1 -1
  8. package/renderer/app-management.js +91 -45
  9. package/renderer/app-run-modal.js +1 -1
  10. package/renderer/app.js +1 -1
  11. package/renderer/i18n-messages.js +212 -199
  12. package/renderer/index.html +28 -27
  13. package/renderer/styles-agent-map.css +10 -10
  14. package/renderer/styles-cards.css +19 -19
  15. package/renderer/styles-collaboration.css +23 -23
  16. package/renderer/styles-components.css +36 -36
  17. package/renderer/styles-management.css +23 -14
  18. package/renderer/styles-onboarding.css +8 -8
  19. package/renderer/styles-overlays.css +5 -5
  20. package/renderer/styles-product.css +24 -24
  21. package/renderer/styles-quality.css +4 -4
  22. package/renderer/styles-readability.css +179 -1
  23. package/renderer/styles-responsive-runtime.css +5 -5
  24. package/renderer/styles-responsive-workflows.css +2 -2
  25. package/renderer/styles-run-composer.css +23 -23
  26. package/renderer/styles-runtime-overview.css +22 -22
  27. package/renderer/styles-settings.css +21 -21
  28. package/renderer/styles-terminal.css +66 -66
  29. package/renderer/styles-tmux.css +49 -49
  30. package/renderer/styles-workflows.css +48 -48
  31. package/renderer/styles.css +7 -7
  32. package/renderer/terminal-events.js +34 -11
  33. package/renderer/terminal-workbench.js +52 -21
  34. package/renderer/terminal.js +9 -1
  35. package/src/contracts.js +5 -5
  36. package/src/ipc/registerTerminalIpc.js +6 -14
  37. package/src/sessionIntelligence.js +18 -4
  38. package/src/terminalHost.js +100 -26
  39. package/src/terminalHostDaemon.js +1 -0
  40. package/src/terminalManager.js +61 -4
package/README.ko.md CHANGED
@@ -70,14 +70,14 @@ LoadToAgent는 시작할 때 현재 패키지 버전과 GitHub의 최신 정식
70
70
  - macOS 또는 Windows
71
71
  - npm으로 설치할 때만 Node.js 18 이상
72
72
  - Claude Code, Codex CLI, Gemini CLI, Grok CLI 중 하나 이상 설치 및 로그인
73
- - tmux 작업 지도를 사용할 때만 tmux 필요
73
+ - tmux 터미널 관리를 사용할 때만 tmux 필요
74
74
 
75
75
  ## 처음 10분 사용법
76
76
 
77
77
  1. **홈**에서 `새 AI 작업`을 누르고 할 일과 작업 폴더를 고릅니다. 설치된 AI가 없으면 화면의 공식 설치 안내를 먼저 따르세요.
78
78
  2. **진행 중**에서 초록 상태의 AI를 확인합니다. 도움 AI를 함께 쓰는 작업은 접힌 `상세 흐름 보기`에서 펼칠 수 있습니다.
79
- 3. **내 확인 필요**에 숫자가 생기면 답변이나 선택이 필요한 작업부터 처리합니다.
80
- 4. 작업 카드를 열어 **대화·진행 과정·사용량**을 확인하고, 연결된 작업은 **세션 터미널**에서 이어서 입력합니다.
79
+ 3. **확인할 일**에 숫자가 생기면 응답이 필요한 작업과 상태 위험 신호를 구분해 확인합니다.
80
+ 4. 작업 카드를 열어 **대화·진행 과정·사용량**을 확인하고, 연결된 작업은 **AI 세션 터미널**에서 이어서 입력합니다.
81
81
 
82
82
  홈의 `10분 시작 가이드`도 같은 네 단계를 직접 눌러 연습하게 해 줍니다. 완료 상태는 이 컴퓨터에 저장되며 언제든 다시 펼칠 수 있습니다.
83
83
 
@@ -87,18 +87,20 @@ LoadToAgent는 시작할 때 현재 패키지 버전과 GitHub의 최신 정식
87
87
  |---|---|
88
88
  | AI 작업 지도 | Claude, Codex, Gemini, Grok별 실시간 작업 |
89
89
  | 연결 관계 | 사용자 요청, 선택한 메인 AI, 직접 나눠 맡긴 도움 AI |
90
- | 실행 단위 | AI가 시작한 포그라운드 셸·백그라운드 셸·백그라운드 작업의 명령, 작업 폴더, 실행 ID와 현재 상태 |
91
- | 운영 현황·확인함 | 실패·정체·컨텍스트 위험과 승인·결정·입력 요청을 우선순위로 모아 즉시 처리 |
90
+ | 명령·백그라운드 실행 | AI가 시작한 일반 명령과 백그라운드 명령의 내용, 작업 폴더, 실행 ID와 현재 상태 |
91
+ | 응답·상태 확인함 | 최근 24시간 세션에서 응답이 필요한 요청과 현재 실행의 실패·지연·일시정지 위험만 확인 |
92
92
  | 작업 상세 | 대화, 도구 호출, 진행 과정, 모델, 작업 폴더, 상태 |
93
- | 관리 요약 | 체크포인트, 관측 신뢰도, 완료 요약, 산출물과 검증 결과, 실행 제어 |
93
+ | 관리 요약 | 최근 실행 이벤트, 상태 정보 확인 수준, 완료 신호, 로그에서 찾은 산출물 후보와 테스트 기록, 실행 제어 |
94
94
  | 토큰 | 입력·출력·캐시·추론·전체 사용량과 보고된 컨텍스트 점유율 |
95
- | 세션 터미널 | 선택한 AI의 이전 대화와 기존 PTY·tmux 화면을 나란히 보며 같은 세션에 이어서 입력 |
96
- | tmux 작업 | macOS 또는 Windows WSL의 세션 → 창 → 패널 → AI 프로세스 관계 |
95
+ | AI 세션 터미널 | 선택한 AI의 이전 대화와 기존 PTY·tmux 화면을 나란히 보며 같은 세션에 이어서 입력 |
96
+ | tmux 터미널 관리 | macOS 또는 Windows WSL의 tmux 세션 → 창 → 패널 → AI 프로세스 관계 |
97
97
 
98
- 스케줄·루프, 세션 터미널, tmux 작업은 왼쪽의 **고급 도구** 아래에 모여 있어 일상적인 관제 흐름을 방해하지 않습니다.
98
+ 예약·실행 단계, AI 세션 터미널, tmux 터미널 관리는 왼쪽의 **고급 도구** 아래에 모여 있어 일상적인 관제 흐름을 방해하지 않습니다.
99
99
 
100
100
  LoadToAgent는 `직접 입력 가능`, `브리지 연결 후 입력 가능`, `원래 앱에서 계속해야 하는 보기 전용`, `종료된 세션`을 구분합니다. 임의의 외부 창에 키 입력을 보내지 않습니다.
101
101
 
102
+ 홈과 세션 목록은 현재 실행 중이거나 마지막 활동이 24시간 이내인 세션을 표시합니다. **확인할 일**에는 이 최근 세션 중 실제 응답 요청 또는 현재 실행의 실패·지연·일시정지 위험 신호만 포함하며, 정보 확인 수준이 낮다는 이유만으로는 포함하지 않습니다.
103
+
102
104
  ## 연결된 터미널 사용
103
105
 
104
106
  LoadToAgent 앱을 열어 둔 뒤 인증된 로컬 브리지로 AI CLI를 시작합니다.
@@ -11,17 +11,19 @@ LoadToAgent는 제공사별 이벤트를 아래 공통 단계로 정규화합니
11
11
  제공사별 원본 세션은 화면에 전달되기 전에 다음 관리 정보로 보강됩니다.
12
12
 
13
13
  - `attention`: 승인·결정·입력·오류·일시정지처럼 사용자 조치가 필요한 이유와 관측 신뢰도
14
- - `progress`: 현재 단계, 완료율, 최근 체크포인트, 막힘 사유와 마지막 활동 시각
15
- - `health`: 실패, 정체, 장시간 대기, 컨텍스트 위험, 반복 실패, 고아 에이전트 신호
14
+ - `progress`: 최근 실행 이벤트 중 완료로 기록된 수, 최근 체크포인트, 막힘 사유와 마지막 활동 시각. 화면의 비율은 전체 계획 진척도가 아닙니다.
15
+ - `health`: 실패, 활동 없음, 장시간 응답 대기, AI 기억 한도, 반복 실패, 부모 연결 정보 누락 신호
16
16
  - `controlCapabilities`: 실제 연결과 관리 실행 상태에 근거한 응답·중지·일시정지·재개·재시도·재배정 가능 여부
17
- - `evidence`: 상태·계층·완료 판단이 직접 관측인지 추론인지와 근거 출처
18
- - `outcome`: 완료 요약, 감지된 파일·테스트·커밋, 검증 이벤트와 완료 관측 여부
17
+ - `evidence`: 상태·부모/도움 AI 관계·완료 신호가 직접 확인인지 활동 기록 추정인지와 정보 출처. 확인 수준은 결과 품질의 신뢰도가 아닙니다.
18
+ - `outcome`: 완료 요약, 로그에서 찾은 파일·테스트·커밋 후보, 테스트 관련 로그와 완료 신호 수신 여부. 파일 존재나 결과 정확성을 독립 검증한 값이 아닙니다.
19
19
 
20
20
  관측 이벤트가 없는 값은 사실로 단정하지 않고 `inferred` 또는 `unverified`로 표시합니다. 시간 기반 건강 신호는 매분 다시 계산됩니다.
21
21
 
22
- ## 셸·백그라운드 실행 계약
22
+ 화면의 최근 세션 기준은 마지막 활동 후 24시간입니다. 실행 중인 세션은 마지막 활동 시각과 관계없이 계속 표시합니다. `확인할 일`은 이 범위 안의 실제 응답 요청과 현재 실행 위험만 집계하며, 오래된 세션이나 단순한 낮은 확인 수준은 제외합니다.
23
23
 
24
- - AI 세션은 `executions`에 자신이 시작한 셸과 백그라운드 실행을 별도 실행 단위로 보관합니다. 서브에이전트 세션과 섞어 세지 않습니다.
24
+ ## 명령·백그라운드 실행 계약
25
+
26
+ - 각 AI 세션은 `executions`에 자신이 시작한 일반 명령과 백그라운드 명령을 별도 기록으로 보관합니다. 도움 AI 세션과 섞어 세지 않습니다.
25
27
  - 공통 필드는 `kind(shell|background)`, `mode(foreground|background)`, `status(running|completed|failed)`, 명령, 작업 폴더, 호출 ID, 시작·갱신·완료 시각입니다.
26
28
  - Codex는 `shell_command`, `exec_command`와 `custom_tool_call: exec` 안의 `tools.exec_command(...)`를 인식합니다. 장기 실행이 반환한 cell/session ID는 후속 `wait`·`write_stdin` 호출과 연결합니다.
27
29
  - Claude는 `Bash` 계열의 `run_in_background`와 후속 `TaskOutput`·`BashOutput`을 같은 실행으로 연결합니다. Gemini/Grok의 구조화 `tool_use`·`tool_result`에서도 셸 도구를 같은 계약으로 정규화합니다.
package/main.js CHANGED
@@ -10,7 +10,7 @@ const { execFileSync } = require('child_process');
10
10
  const { AgentRunner, probeProviders } = require('./src/agentRunner');
11
11
  const { providerList, blankUsage } = require('./src/providerRegistry');
12
12
  const { TerminalManager } = require('./src/terminalManager');
13
- const { TerminalHostClient, launchTerminalHost } = require('./src/terminalHost');
13
+ const { TerminalHostClient, launchTerminalHost, resolveTerminalHostExecutable } = require('./src/terminalHost');
14
14
  const { TmuxController } = require('./src/tmuxController');
15
15
  const { normalizeWslList } = require('./src/tmuxMonitor');
16
16
  const { UpdateManager } = require('./src/updateManager');
@@ -59,6 +59,9 @@ const MAIN_COPY = {
59
59
  pickWorkspace: '작업 폴더 선택',
60
60
  attentionTitle: '내 확인이 필요합니다',
61
61
  attentionBody: '{provider} · {title}',
62
+ terminalHostReconnecting: '터미널 연결을 자동으로 복구하는 중입니다.',
63
+ terminalHostReconnected: '터미널 연결을 복구했습니다.',
64
+ terminalHostReconnectFailed: '터미널 연결을 복구하지 못했습니다: {reason}',
62
65
  },
63
66
  en: {
64
67
  trayTooltip: 'LoadToAgent · {count} background terminals',
@@ -69,6 +72,9 @@ const MAIN_COPY = {
69
72
  pickWorkspace: 'Choose workspace',
70
73
  attentionTitle: 'Your review is needed',
71
74
  attentionBody: '{provider} · {title}',
75
+ terminalHostReconnecting: 'Restoring the terminal connection automatically.',
76
+ terminalHostReconnected: 'Terminal connection restored.',
77
+ terminalHostReconnectFailed: 'Could not restore the terminal connection: {reason}',
72
78
  },
73
79
  'zh-CN': {
74
80
  trayTooltip: 'LoadToAgent · {count} 个后台终端',
@@ -79,6 +85,9 @@ const MAIN_COPY = {
79
85
  pickWorkspace: '选择工作文件夹',
80
86
  attentionTitle: '需要你的确认',
81
87
  attentionBody: '{provider} · {title}',
88
+ terminalHostReconnecting: '正在自动恢复终端连接。',
89
+ terminalHostReconnected: '终端连接已恢复。',
90
+ terminalHostReconnectFailed: '无法恢复终端连接:{reason}',
82
91
  },
83
92
  };
84
93
  let lastSnapshot = {
@@ -388,7 +397,7 @@ async function setupRuntime() {
388
397
  : new TerminalHostClient({
389
398
  discoveryFile: terminalHostFile,
390
399
  spawnHost: () => launchTerminalHost({
391
- executable: process.execPath,
400
+ executable: resolveTerminalHostExecutable({ isPackaged: app.isPackaged }),
392
401
  script: path.join(__dirname, 'src', 'terminalHostDaemon.js'),
393
402
  storeFile: terminalStoreFile,
394
403
  discoveryFile: terminalHostFile,
@@ -426,7 +435,20 @@ async function setupRuntime() {
426
435
  if (monitorWorker) monitorWorker.postMessage({ type: 'bridge-presence', bridges: bridgePresence() });
427
436
  });
428
437
  terminalManager.on('disconnect', () => {
429
- sendTerminal('terminals:error', { id: '', message: '터미널 호스트 연결이 끊어졌습니다. 프로그램을 다시 시작하면 실행 중인 세션에 다시 연결합니다.' });
438
+ sendTerminal('terminals:connection', { state: 'reconnecting', message: mainText('terminalHostReconnecting') });
439
+ });
440
+ terminalManager.on('reconnect', payload => {
441
+ const sessions = visibleTerminalSessions(payload?.sessions || terminalManager.list());
442
+ sendTerminal('terminals:state', { change: 'reconnected', session: null, sessions });
443
+ sendTerminal('terminals:connection', { state: 'connected', message: mainText('terminalHostReconnected') });
444
+ updateBackgroundTrayMenu();
445
+ if (monitorWorker) monitorWorker.postMessage({ type: 'bridge-presence', bridges: bridgePresence() });
446
+ });
447
+ terminalManager.on('reconnect-error', error => {
448
+ sendTerminal('terminals:connection', {
449
+ state: 'failed',
450
+ message: mainText('terminalHostReconnectFailed', { reason: error?.message || String(error) }),
451
+ });
430
452
  });
431
453
  await terminalManager.connect();
432
454
  availability = probeProviders();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loadtoagent",
3
- "version": "1.3.1",
3
+ "version": "1.3.3",
4
4
  "description": "A local desktop dashboard for Claude, Codex, Gemini, and Grok sessions.",
5
5
  "main": "main.js",
6
6
  "author": "wincube AX",
package/preload.js CHANGED
@@ -35,9 +35,9 @@ contextBridge.exposeInMainWorld('loadtoagent', {
35
35
  wslDistros: () => ipcRenderer.invoke('wsl:list-distros'),
36
36
  terminalGet: id => ipcRenderer.invoke('terminals:get', id),
37
37
  terminalCreate: options => ipcRenderer.invoke('terminals:create', options),
38
- terminalWrite: (id, data) => ipcRenderer.send('terminals:write', id, data),
38
+ terminalWrite: (id, data) => ipcRenderer.invoke('terminals:write', id, data),
39
39
  terminalCommand: (id, command) => ipcRenderer.invoke('terminals:command', id, command),
40
- terminalResize: (id, cols, rows) => ipcRenderer.send('terminals:resize', id, cols, rows),
40
+ terminalResize: (id, cols, rows) => ipcRenderer.invoke('terminals:resize', id, cols, rows),
41
41
  terminalSignal: (id, signal) => ipcRenderer.invoke('terminals:signal', id, signal),
42
42
  terminalRestart: id => ipcRenderer.invoke('terminals:restart', id),
43
43
  terminalClose: id => ipcRenderer.invoke('terminals:close', id),
@@ -68,6 +68,11 @@ contextBridge.exposeInMainWorld('loadtoagent', {
68
68
  ipcRenderer.on('terminals:error', handler);
69
69
  return () => ipcRenderer.removeListener('terminals:error', handler);
70
70
  },
71
+ onTerminalConnection: callback => {
72
+ const handler = (_event, payload) => callback(payload);
73
+ ipcRenderer.on('terminals:connection', handler);
74
+ return () => ipcRenderer.removeListener('terminals:connection', handler);
75
+ },
71
76
  onSnapshot: callback => {
72
77
  const handler = (_event, snapshot) => callback(snapshot);
73
78
  ipcRenderer.on('agents:snapshot', handler);
@@ -18,6 +18,12 @@ window.LoadToAgentAppFactories.createDashboard = function createDashboard(contex
18
18
  isRuntimeLoopSession = () => false,
19
19
  } = context;
20
20
 
21
+ function displaySessions() {
22
+ return visibleSessions().filter((session) => (
23
+ typeof context.isRecentSession !== "function" || context.isRecentSession(session)
24
+ ));
25
+ }
26
+
21
27
  function renderProviderRail() {
22
28
  $("#providerRail").innerHTML = visibleProviders()
23
29
  .map((provider) => {
@@ -62,7 +68,7 @@ window.LoadToAgentAppFactories.createDashboard = function createDashboard(contex
62
68
  const projects = new Map();
63
69
  const saved = state.workspaces.map((item) => ({ ...item, key: normalizedProjectPath(item.path) }));
64
70
  saved.forEach((item) => projects.set(item.key, { path: item.path, name: item.name || projectName(item.path), saved: true, count: 0 }));
65
- visibleSessions().filter((session) => !session.parentId && !isProjectlessSession(session)).forEach((session) => {
71
+ displaySessions().filter((session) => !session.parentId && !isProjectlessSession(session)).forEach((session) => {
66
72
  const originPath = sessionOriginPath(session);
67
73
  if (!originPath) return;
68
74
  const owner = saved
@@ -104,7 +110,7 @@ window.LoadToAgentAppFactories.createDashboard = function createDashboard(contex
104
110
 
105
111
  function renderWorkspaces() {
106
112
  const lists = [$("#workspaceList"), $("#mobileWorkspaceList")].filter(Boolean);
107
- const rootSessions = visibleSessions().filter((session) => !session.parentId);
113
+ const rootSessions = displaySessions().filter((session) => !session.parentId);
108
114
  const projects = observedProjects();
109
115
  const projectlessCount = rootSessions.filter(isProjectlessSession).length;
110
116
  const savedWorkspaceExists = state.workspace === "all"
@@ -149,15 +155,15 @@ window.LoadToAgentAppFactories.createDashboard = function createDashboard(contex
149
155
  }
150
156
 
151
157
  function renderGlobalStats() {
152
- const sessions = visibleSessions();
158
+ const sessions = displaySessions();
153
159
  const totals = {
154
160
  active: sessions.filter((session) => session.status === "running" || session.status === "starting").length,
155
- waiting: sessions.filter((session) => session.attention?.required).length,
161
+ waiting: sessions.filter((session) => context.matchesManagementFilter?.(session, "attention")).length,
156
162
  usage: { total: sessions.reduce((sum, session) => sum + Number(session.usage && session.usage.total || 0), 0) },
157
163
  };
158
164
  const rootCount = sessions.filter((session) => !session.parentId).length;
159
- const criticalCount = sessions.filter((session) => session.health?.level === "critical").length;
160
- const riskCount = sessions.filter((session) => session.health?.level === "warning" || Number(session.context?.percent || 0) >= 75).length;
165
+ const criticalCount = sessions.filter((session) => context.matchesManagementFilter?.(session, "critical")).length;
166
+ const riskCount = sessions.filter((session) => context.matchesManagementFilter?.(session, "warning")).length;
161
167
  const items = [
162
168
  [window.LoadToAgentI18n.t("ui.all_tasks"), rootCount, window.LoadToAgentI18n.t("ui.items"), ""],
163
169
  [window.LoadToAgentI18n.t("ui.ai_working_now"), totals.active || 0, window.LoadToAgentI18n.t("ui.items"), "live"],
@@ -177,7 +183,7 @@ window.LoadToAgentAppFactories.createDashboard = function createDashboard(contex
177
183
  $("#navAllCount").textContent = rootCount;
178
184
  const activeRootCount = sessions.filter((session) => !session.parentId && ["running", "starting"].includes(session.status)).length;
179
185
  $("#navActiveCount").textContent = activeRootCount;
180
- const reviewCount = sessions.filter((session) => context.managementBucket?.(session) !== "healthy").length;
186
+ const reviewCount = sessions.filter((session) => context.needsManagementReview?.(session)).length;
181
187
  $("#navWaitingCount").textContent = reviewCount;
182
188
  const scheduledCount = (state.snapshot?.automations || [])
183
189
  .filter((item) => isProviderVisible(item.provider || "codex")).length;
@@ -201,7 +207,7 @@ window.LoadToAgentAppFactories.createDashboard = function createDashboard(contex
201
207
  }[button.dataset.view];
202
208
  const label = t(key);
203
209
  const count = navCounts[button.dataset.view];
204
- const unitKey = { all: "tasks", active: "tasks", waiting: "tasks", runtime: "runs", terminal: "sessions", tmux: "groups" }[button.dataset.view];
210
+ const unitKey = { all: "tasks", active: "tasks", waiting: "items", runtime: "runs", terminal: "sessions", tmux: "sessions" }[button.dataset.view];
205
211
  const unit = unitKey ? t(`quality.unit.${unitKey}`) : "";
206
212
  const accessibleLabel = Number.isFinite(count) ? t("quality.nav_count_detailed", { label, count, unit }) : label;
207
213
  button.setAttribute("aria-label", accessibleLabel);
@@ -341,7 +347,7 @@ window.LoadToAgentAppFactories.createDashboard = function createDashboard(contex
341
347
  function renderProviderOverview() {
342
348
  pruneProviderFilters();
343
349
  const summaries = (state.snapshot && state.snapshot.summary && state.snapshot.summary.providers) || state.providers;
344
- const sessions = visibleSessions();
350
+ const sessions = displaySessions();
345
351
  const visibleSummaries = summaries.filter((provider) => isProviderVisible(provider.id));
346
352
  const overviewTabStopId = state.providerFilters.size ? [...state.providerFilters][0] : visibleSummaries[0]?.id;
347
353
  $("#providerOverview").innerHTML = visibleSummaries
@@ -418,10 +424,10 @@ window.LoadToAgentAppFactories.createDashboard = function createDashboard(contex
418
424
  }
419
425
 
420
426
  function filteredSessions() {
421
- const allSessions = [...visibleSessions()];
427
+ const allSessions = displaySessions();
422
428
  let sessions = state.view === "waiting" ? allSessions : allSessions.filter((session) => !session.parentId);
423
429
  if (state.view === "active") sessions = sessions.filter((session) => session.status === "running" || session.status === "starting");
424
- if (state.view === "waiting") sessions = sessions.filter((session) => session.attention?.required || ["critical", "warning", "unknown"].includes(session.health?.level) || !session.health);
430
+ if (state.view === "waiting") sessions = sessions.filter((session) => context.needsManagementReview?.(session));
425
431
  if (state.providerFilters.size) sessions = sessions.filter((session) => state.providerFilters.has(session.provider));
426
432
  sessions = sessions.filter(matchesWorkspaceFilter);
427
433
  const query = state.search.replace(/\s+/g, " ").trim().toLowerCase();
@@ -445,7 +451,7 @@ window.LoadToAgentAppFactories.createDashboard = function createDashboard(contex
445
451
  }
446
452
 
447
453
  function graphFilteredSessions() {
448
- let sessions = [...visibleSessions()];
454
+ let sessions = displaySessions();
449
455
  if (state.providerFilters.size) sessions = sessions.filter((session) => state.providerFilters.has(session.provider));
450
456
  sessions = sessions.filter(matchesWorkspaceFilter);
451
457
  const query = state.search.replace(/\s+/g, " ").trim().toLowerCase();
@@ -33,7 +33,7 @@ window.LoadToAgentAppFactories.createGraphView = function createGraphView(contex
33
33
  } = context;
34
34
  const t = (key, params) => window.LoadToAgentI18n.t(key, params);
35
35
  const statusLabel = (status) => ({
36
- starting: t("ui.preparing"), running: t("ui.working"), waiting: t("app.nav.needs_review"), idle: t("ui.idle"),
36
+ starting: t("ui.preparing"), running: t("ui.working"), waiting: t("ui.waiting_for_review"), idle: t("ui.idle"),
37
37
  completed: t("ui.completed"), failed: t("ui.problem"), cancelled: t("ui.stopped"),
38
38
  })[status] || STATUS[status] || status;
39
39
 
@@ -10,6 +10,33 @@ window.LoadToAgentAppFactories.createManagement = function createManagement(cont
10
10
  const healthLabel = level => t(`management.health.${level || "unknown"}`);
11
11
  const signalLabel = code => t(`management.signal.${code || "low-confidence"}`);
12
12
  const evidenceLabel = value => t(`management.evidence.${value || "unverified"}`);
13
+ const RECENT_SESSION_WINDOW_MS = 24 * 60 * 60 * 1000;
14
+ const ALWAYS_VISIBLE_STATUSES = new Set(["starting", "running"]);
15
+ const CURRENT_RISK_STATUSES = new Set(["starting", "running", "waiting", "paused", "failed"]);
16
+ const RESPONSE_ATTENTION_KINDS = new Set(["approval", "decision", "input", "response"]);
17
+ const needsUserResponse = session => Boolean(
18
+ session.attention?.required && RESPONSE_ATTENTION_KINDS.has(session.attention.kind),
19
+ );
20
+ const sessionActivityTimestamp = session => Math.max(0, ...[
21
+ session.health?.lastActivityAt,
22
+ session.attention?.requestedAt,
23
+ session.updatedAt,
24
+ session.completedAt,
25
+ session.startedAt,
26
+ ].map(value => Date.parse(value || 0)).filter(Number.isFinite));
27
+ const isRecentSession = (session, now = Date.now()) => {
28
+ if (ALWAYS_VISIBLE_STATUSES.has(session.status)) return true;
29
+ const activityAt = sessionActivityTimestamp(session);
30
+ return Boolean(activityAt && Math.max(0, Number(now) - activityAt) <= RECENT_SESSION_WINDOW_MS);
31
+ };
32
+ const hasCurrentRisk = (session, now = Date.now()) => Boolean(
33
+ isRecentSession(session, now)
34
+ && CURRENT_RISK_STATUSES.has(session.status)
35
+ && ["critical", "warning"].includes(session.health?.level),
36
+ );
37
+ const needsManagementReview = (session, now = Date.now()) => Boolean(
38
+ isRecentSession(session, now) && (needsUserResponse(session) || hasCurrentRisk(session, now)),
39
+ );
13
40
  const prioritySummary = value => {
14
41
  const lines = String(value || "")
15
42
  .replace(/```[\s\S]*?```/g, " ")
@@ -37,22 +64,32 @@ window.LoadToAgentAppFactories.createManagement = function createManagement(cont
37
64
  return readablePreview(firstSentence || t("management.signal_unavailable"), 104).text;
38
65
  };
39
66
 
40
- function managementBucket(session) {
41
- if (session.health?.level === "critical") return "critical";
42
- if (session.health?.level === "warning") return "warning";
43
- if (session.attention?.required) return "attention";
44
- if (!session.health || session.health.level === "unknown") return "unknown";
45
- return session.health.level === "healthy" ? "healthy" : "unknown";
67
+ function managementBucket(session, now = Date.now()) {
68
+ if (!needsManagementReview(session, now)) return "healthy";
69
+ if (hasCurrentRisk(session, now) && session.health?.level === "critical") return "critical";
70
+ if (hasCurrentRisk(session, now) && session.health?.level === "warning") return "warning";
71
+ if (needsUserResponse(session)) return "attention";
72
+ return "healthy";
73
+ }
74
+
75
+ function matchesManagementFilter(session, filter, now = Date.now()) {
76
+ if (filter === "critical") return hasCurrentRisk(session, now) && session.health?.level === "critical";
77
+ if (filter === "warning") return hasCurrentRisk(session, now) && session.health?.level === "warning";
78
+ if (filter === "attention") return isRecentSession(session, now) && needsUserResponse(session);
79
+ return needsManagementReview(session, now);
46
80
  }
47
81
 
48
82
  function progressHtml(session, compactView = false) {
49
83
  const progress = session.progress || { percent: 0, checkpoints: [] };
50
84
  const checkpoints = compactView ? [] : (progress.checkpoints || []).slice(-8);
85
+ const completed = Number(progress.completedSteps || 0);
86
+ const total = Number(progress.totalSteps || 0);
87
+ const loggedRatio = total ? Math.round(completed / total * 100) : 0;
51
88
  return `<section class="management-progress" data-progress-stage="${esc(progress.stage || "idle")}">
52
89
  <header><span>${esc(t("management.progress"))}</span><b>${esc(t("management.progress_steps", {
53
- completed: progress.completedSteps || 0, total: progress.totalSteps || 0,
54
- }))}</b><strong>${Number(progress.percent || 0)}%</strong></header>
55
- <div class="management-progress-track" role="progressbar" aria-label="${esc(t("management.progress"))}" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${Number(progress.percent || 0)}"><i style="width:${Math.max(0, Math.min(100, Number(progress.percent || 0)))}%"></i></div>
90
+ completed, total,
91
+ }))}</b><strong>${completed}/${total}</strong></header>
92
+ <div class="management-progress-track" role="progressbar" aria-label="${esc(t("management.progress_steps", { completed, total }))}" aria-valuemin="0" aria-valuemax="${Math.max(1, total)}" aria-valuenow="${completed}"><i style="width:${Math.max(0, Math.min(100, loggedRatio))}%"></i></div>
56
93
  <p><b>${esc(progress.currentStep || t("management.no_current_step"))}</b><span>${esc(progress.lastActivityAt ? t("management.last_signal", { time: timeAgo(progress.lastActivityAt) }) : t("management.signal_unavailable"))}</span></p>
57
94
  ${progress.blocker ? `<aside><span>${esc(t("management.blocker"))}</span><b>${esc(progress.blocker)}</b></aside>` : ""}
58
95
  ${checkpoints.length ? `<ol class="management-checkpoints">${checkpoints.map(row => `<li class="${esc(row.status)}"><i></i><div><b>${esc(row.label)}</b>${row.detail ? `<small>${esc(row.detail)}</small>` : ""}</div><span>${esc(row.timestamp ? timeAgo(row.timestamp) : "")}</span></li>`).join("")}</ol>` : ""}
@@ -63,7 +100,7 @@ window.LoadToAgentAppFactories.createManagement = function createManagement(cont
63
100
  const health = session.health || { level: "unknown", signals: [] };
64
101
  const signals = health.signals || [];
65
102
  return `<section class="management-health ${esc(health.level || "unknown")}">
66
- <header><span>${esc(t("management.health_title"))}</span><b>${esc(healthLabel(health.level))}</b><strong>${Number(health.score == null ? 0 : health.score)}</strong></header>
103
+ <header><span>${esc(t("management.health_title"))}</span><b>${esc(healthLabel(health.level))}</b><strong>${esc(t("common.count", { count: signals.length }))}</strong></header>
67
104
  ${signals.length
68
105
  ? `<ul>${signals.slice(0, compactView ? 2 : 8).map(signal => `<li class="${esc(signal.severity)}"><i></i><b>${esc(signalLabel(signal.code))}</b>${signal.detail ? `<span title="${esc(signal.detail)}">${esc(signal.detail)}</span>` : ""}</li>`).join("")}</ul>`
69
106
  : `<p>${esc(t("management.no_health_signals"))}</p>`}
@@ -92,7 +129,10 @@ window.LoadToAgentAppFactories.createManagement = function createManagement(cont
92
129
  if (!attention.required) return "";
93
130
  const controls = session.controlCapabilities || {};
94
131
  const buttons = [];
95
- if (controls.sendInstruction && ["approval", "decision"].includes(attention.kind)) {
132
+ // A yes/no approval message is only safe for explicit approval requests.
133
+ // Decisions may require selecting a concrete option, so those use the
134
+ // normal instruction composer instead of a misleading generic approval.
135
+ if (controls.sendInstruction && attention.kind === "approval") {
96
136
  buttons.push(`<button type="button" class="approve" data-attention-session-id="${esc(session.id)}" data-attention-quick="${esc(t("management.quick.approve_text"))}">${esc(t("management.approve"))}</button>`);
97
137
  buttons.push(`<button type="button" data-attention-session-id="${esc(session.id)}" data-attention-quick="${esc(t("management.quick.deny_text"))}">${esc(t("management.deny"))}</button>`);
98
138
  }
@@ -103,10 +143,19 @@ window.LoadToAgentAppFactories.createManagement = function createManagement(cont
103
143
  function attentionCardHtml(session) {
104
144
  const provider = providerInfo(session.provider);
105
145
  const attention = session.attention || {};
146
+ const health = session.health || { level: "unknown", signals: [] };
106
147
  const evidence = session.evidence || {};
148
+ const cardLabel = attention.required ? attentionLabel(attention.kind) : healthLabel(health.level);
149
+ const statusSignals = (health.signals || []).map(signal => {
150
+ const label = signalLabel(signal.code);
151
+ return signal.detail ? `${label} · ${signal.detail}` : label;
152
+ }).join(" / ");
153
+ const reason = attention.required
154
+ ? (attention.summary || session.statusDetail || t("management.response_needed"))
155
+ : (statusSignals || session.statusDetail || t("management.signal_unavailable"));
107
156
  return `<article class="attention-card ${esc(attention.kind || "response")}" data-management-session="${esc(session.id)}" style="--management-provider:${provider.accent}">
108
- <header><span class="provider-mark">${esc(provider.mark)}</span><div><small>${esc(provider.label)} · ${esc(attentionLabel(attention.kind))}</small><h3>${esc(session.title)}</h3></div><em class="confidence ${esc(evidence.confidence || "low")}">${esc(evidenceLabel(evidence.confidence))}</em></header>
109
- <div class="attention-reason"><span>${esc(t("management.requested_action"))}</span><p>${esc(attention.summary || session.statusDetail || t("management.response_needed"))}</p><small>${esc(attention.requestedAt ? timeAgo(attention.requestedAt) : t("management.signal_unavailable"))}</small></div>
157
+ <header><span class="provider-mark">${esc(provider.mark)}</span><div><small>${esc(provider.label)} · ${esc(cardLabel)}</small><h3>${esc(session.title)}</h3></div><em class="confidence ${esc(evidence.confidence || "low")}">${esc(evidenceLabel(evidence.confidence))}</em></header>
158
+ <div class="attention-reason"><span>${esc(t(attention.required ? "management.requested_action" : "management.health_title"))}</span><p>${esc(reason)}</p><small>${esc(attention.requestedAt ? timeAgo(attention.requestedAt) : health.lastActivityAt ? timeAgo(health.lastActivityAt) : t("management.signal_unavailable"))}</small></div>
110
159
  ${progressHtml(session, true)}
111
160
  ${healthHtml(session, true)}
112
161
  ${quickActionsHtml(session)}
@@ -118,22 +167,20 @@ window.LoadToAgentAppFactories.createManagement = function createManagement(cont
118
167
  function renderAttentionInbox() {
119
168
  const section = $("#attentionInbox");
120
169
  if (!section) return 0;
121
- const reviewSessions = context.filteredSessions().filter(session => managementBucket(session) !== "healthy");
122
- const filter = ["critical", "warning", "attention", "unknown"].includes(state.managementFilter) ? state.managementFilter : "all";
123
- const sessions = filter === "all" ? reviewSessions : reviewSessions.filter(session => managementBucket(session) === filter);
124
- const counts = reviewSessions.reduce((output, session) => {
125
- const key = managementBucket(session);
126
- output[key] = (output[key] || 0) + 1;
127
- return output;
128
- }, { critical: 0, warning: 0, attention: 0, unknown: 0 });
170
+ const reviewSessions = context.filteredSessions().filter(needsManagementReview);
171
+ const filter = ["critical", "warning", "attention"].includes(state.managementFilter) ? state.managementFilter : "all";
172
+ const sessions = reviewSessions.filter(session => filter === "all" || matchesManagementFilter(session, filter));
173
+ const counts = {
174
+ critical: reviewSessions.filter(session => matchesManagementFilter(session, "critical")).length,
175
+ warning: reviewSessions.filter(session => matchesManagementFilter(session, "warning")).length,
176
+ attention: reviewSessions.filter(session => matchesManagementFilter(session, "attention")).length,
177
+ };
129
178
  const filterButton = (value, label, count) => `<button type="button" data-management-inbox-filter="${value}" aria-pressed="${filter === value ? "true" : "false"}"><i></i><span>${esc(label)}</span><b>${count}</b></button>`;
130
179
  section.innerHTML = `<header class="attention-inbox-head"><div><p>${esc(t("management.inbox_eyebrow"))}</p><h2>${esc(t("management.inbox_title"))}</h2><span>${esc(t("management.inbox_description"))}</span></div><strong>${sessions.length}</strong></header>
131
180
  <div class="attention-inbox-summary" role="toolbar" aria-label="${esc(t("management.operations_severity_buckets"))}">
132
- ${filterButton("all", t("management.filter_all"), reviewSessions.length)}
133
- ${filterButton("critical", t("management.health.critical"), counts.critical)}
134
- ${filterButton("warning", t("management.health.warning"), counts.warning)}
135
- ${filterButton("attention", t("management.health.attention"), counts.attention)}
136
- ${filterButton("unknown", t("management.health.unknown"), counts.unknown)}
181
+ <div class="management-filter-all">${filterButton("all", t("management.filter_all"), reviewSessions.length)}</div>
182
+ <div class="management-filter-group response" role="group" aria-label="${esc(t("management.filter_group_response"))}"><small>${esc(t("management.filter_group_response"))}</small>${filterButton("attention", t("management.health.attention"), counts.attention)}</div>
183
+ <div class="management-filter-group risk" role="group" aria-label="${esc(t("management.filter_group_risk"))}"><small>${esc(t("management.filter_group_risk"))}</small>${filterButton("critical", t("management.health.critical"), counts.critical)}${filterButton("warning", t("management.health.warning"), counts.warning)}</div>
137
184
  </div>
138
185
  <div class="attention-card-list">${sessions.length ? sessions.map(attentionCardHtml).join("") : `<div class="management-empty"><b>${esc(t("management.inbox_empty"))}</b><span>${esc(t("management.inbox_empty_detail"))}</span></div>`}</div>`;
139
186
  return sessions.length;
@@ -145,17 +192,13 @@ window.LoadToAgentAppFactories.createManagement = function createManagement(cont
145
192
  const sessions = typeof context.graphFilteredSessions === "function"
146
193
  ? context.graphFilteredSessions()
147
194
  : (state.snapshot?.sessions || []);
148
- const critical = sessions.filter(session => session.health?.level === "critical");
149
- const warning = sessions.filter(session => session.health?.level === "warning");
150
- const attention = sessions.filter(session => session.attention?.required);
151
- const flaggedIds = new Set([...critical, ...warning].map(session => session.id));
152
- const attentionOnly = attention.filter(session => !flaggedIds.has(session.id));
153
- attentionOnly.forEach(session => flaggedIds.add(session.id));
154
- const unknown = sessions.filter(session => !flaggedIds.has(session.id) && (!session.health || session.health.level === "unknown" || !["healthy", "critical", "warning"].includes(session.health.level)));
155
- unknown.forEach(session => flaggedIds.add(session.id));
156
- const healthy = sessions.filter(session => !flaggedIds.has(session.id) && session.health?.level === "healthy");
157
- const priority = [...new Map([...critical, ...warning, ...attentionOnly, ...unknown].map(session => [session.id, session])).values()].slice(0, 4);
158
- const reviewCount = critical.length + warning.length + attentionOnly.length + unknown.length;
195
+ const critical = sessions.filter(session => matchesManagementFilter(session, "critical"));
196
+ const warning = sessions.filter(session => matchesManagementFilter(session, "warning"));
197
+ const responses = sessions.filter(session => matchesManagementFilter(session, "attention"));
198
+ const flaggedIds = new Set([...critical, ...warning, ...responses].map(session => session.id));
199
+ const clear = sessions.filter(session => !flaggedIds.has(session.id));
200
+ const priority = [...new Map([...critical, ...warning, ...responses].map(session => [session.id, session])).values()].slice(0, 4);
201
+ const reviewCount = flaggedIds.size;
159
202
  section.innerHTML = `<header>
160
203
  <div class="operations-heading">
161
204
  <span class="operations-signal" aria-hidden="true">!</span>
@@ -164,16 +207,16 @@ window.LoadToAgentAppFactories.createManagement = function createManagement(cont
164
207
  <div class="operations-review-total"><strong>${reviewCount}</strong><span>${esc(t("management.operations_review_count"))}</span></div>
165
208
  </header>
166
209
  <div class="operations-metrics" aria-label="${esc(t("management.operations_severity_buckets"))}">
167
- <button type="button" data-management-filter="critical"><span>${esc(t("management.health.critical"))}</span><b>${critical.length}</b></button>
168
- <button type="button" data-management-filter="warning"><span>${esc(t("management.health.warning"))}</span><b>${warning.length}</b></button>
169
- <button type="button" data-management-filter="attention"><span>${esc(t("management.health.attention"))}</span><b>${attentionOnly.length}</b></button>
170
- <button type="button" data-management-filter="unknown"><span>${esc(t("management.health.unknown"))}</span><b>${unknown.length}</b></button>
171
- <div><span>${esc(t("management.health.healthy"))}</span><b>${healthy.length}</b></div>
210
+ <button type="button" data-management-filter="critical" data-management-metric="critical"><span>${esc(t("management.health.critical"))}</span><b>${critical.length}</b></button>
211
+ <button type="button" data-management-filter="warning" data-management-metric="warning"><span>${esc(t("management.health.warning"))}</span><b>${warning.length}</b></button>
212
+ <button type="button" data-management-filter="attention" data-management-metric="attention"><span>${esc(t("management.health.attention"))}</span><b>${responses.length}</b></button>
213
+ <div data-management-metric="clear"><span>${esc(t("management.recent_clear"))}</span><b>${clear.length}</b></div>
172
214
  </div>
173
215
  ${priority.length ? `<div class="operations-priority">${priority.map(session => {
174
216
  const health = session.health || {};
175
- const summary = prioritySummary((health.signals || []).map(signal => signalLabel(signal.code)).join(" · ")
176
- || session.attention?.summary
217
+ const responseSummary = needsUserResponse(session) ? session.attention?.summary : "";
218
+ const summary = prioritySummary(responseSummary
219
+ || (health.signals || []).map(signal => signalLabel(signal.code)).join(" · ")
177
220
  || t("management.signal_unavailable"));
178
221
  return `<button type="button" data-open-session="${esc(session.id)}"><i class="${esc(managementBucket(session))}"></i><span><b>${esc(session.title)}</b><small>${esc(summary)}</small></span><em>${esc(timeAgo(health.lastActivityAt || session.updatedAt))}</em></button>`;
179
222
  }).join("")}</div>${reviewCount > priority.length ? `<div class="operations-more"><button type="button" data-management-filter="all"><span>${esc(t("management.operations_view_all"))}</span><b>${reviewCount}</b><i aria-hidden="true">→</i></button></div>` : ""}` : `<p class="operations-clear">${esc(t("management.operations_clear"))}</p>`}`;
@@ -191,7 +234,7 @@ window.LoadToAgentAppFactories.createManagement = function createManagement(cont
191
234
  ${session.attention?.required ? `<section class="management-attention-detail"><header><span>${esc(attentionLabel(session.attention.kind))}</span><b>${esc(session.attention.summary)}</b></header>${quickActionsHtml(session)}${controls.sendInstruction ? context.agentCommandComposer(session) : ""}</section>` : ""}
192
235
  ${progressHtml(session)}
193
236
  ${healthHtml(session)}
194
- <section class="management-artifacts"><header><span>${esc(t("management.artifacts"))}</span><b>${esc(t("common.items", { count: (outcome.artifacts || []).length }))}</b></header>${outcome.artifacts?.length ? `<ul>${outcome.artifacts.map(item => `<li><i>${esc(item.kind)}</i><b title="${esc(item.value)}">${esc(item.value)}</b><span>${esc(item.verified ? t("management.verified") : t("management.detected"))}</span></li>`).join("")}</ul>` : `<p>${esc(t("management.no_artifacts"))}</p>`}</section>
237
+ <section class="management-artifacts"><header><span>${esc(t("management.artifacts"))}</span><b>${esc(t("common.items", { count: (outcome.artifacts || []).length }))}</b></header>${outcome.artifacts?.length ? `<ul>${outcome.artifacts.map(item => `<li><i>${esc(item.kind)}</i><b title="${esc(item.value)}">${esc(item.value)}</b><span>${esc(t("management.detected"))}</span></li>`).join("")}</ul>` : `<p>${esc(t("management.no_artifacts"))}</p>`}</section>
195
238
  <section class="management-checks"><header><span>${esc(t("management.verification_checks"))}</span><b>${esc(t("common.items", { count: (outcome.checks || []).length }))}</b></header>${outcome.checks?.length ? `<ul>${outcome.checks.map(check => `<li class="${esc(check.status)}"><i></i><b>${esc(check.label)}</b><span>${esc(t(`management.check.${check.status}`))}</span></li>`).join("")}</ul>` : `<p>${esc(t("management.no_checks"))}</p>`}</section>
196
239
  <section class="management-evidence"><header><span>${esc(t("management.evidence_title"))}</span><b>${esc(evidenceLabel(evidence.confidence))}</b></header><dl><div><dt>${esc(t("management.evidence_status"))}</dt><dd>${esc(evidenceLabel(evidence.status))}</dd></div><div><dt>${esc(t("management.evidence_hierarchy"))}</dt><dd>${esc(evidenceLabel(evidence.hierarchy))}</dd></div><div><dt>${esc(t("management.evidence_completion"))}</dt><dd>${esc(evidenceLabel(evidence.completion))}</dd></div></dl><p>${esc((evidence.sources || []).join(" · ") || t("management.signal_unavailable"))}</p></section>
197
240
  <section class="management-controls"><header><span>${esc(t("management.controls"))}</span><b>${esc(t("management.controls_description"))}</b></header>${controlButtonsHtml(session)}</section>
@@ -199,7 +242,10 @@ window.LoadToAgentAppFactories.createManagement = function createManagement(cont
199
242
  }
200
243
 
201
244
  return {
245
+ isRecentSession,
202
246
  managementBucket,
247
+ matchesManagementFilter,
248
+ needsManagementReview,
203
249
  attentionCardHtml,
204
250
  controlButtonsHtml,
205
251
  healthHtml,
@@ -83,7 +83,7 @@ window.LoadToAgentAppFactories.createRunModal = function createRunModal(context
83
83
  const active = path === selected;
84
84
  return `<button type="button" data-run-workspace="${esc(path)}" class="${active ? "selected" : ""}" title="${esc(path)}" aria-pressed="${active ? "true" : "false"}">
85
85
  <span aria-hidden="true">⌘</span>
86
- ${esc(workspace.name || path.split(/[\\/]/).filter(Boolean).pop() || window.LoadToAgentI18n.t("ui.workspaces"))}
86
+ ${esc(workspace.name || path.split(/[\\/]/).filter(Boolean).pop() || window.LoadToAgentI18n.t("ui.work_folder"))}
87
87
  </button>`;
88
88
  })
89
89
  .join("");
package/renderer/app.js CHANGED
@@ -156,7 +156,7 @@ window.LoadToAgentAppFactories.createCore = function createCore(context = {}) {
156
156
  get: (_target, property) => keys[property] ? t(keys[property]) : undefined,
157
157
  });
158
158
  const STATUS = localizedLookup({
159
- starting: "ui.preparing", running: "ui.working", paused: "management.status.paused", waiting: "app.nav.needs_review", idle: "ui.idle",
159
+ starting: "ui.preparing", running: "ui.working", paused: "management.status.paused", waiting: "ui.waiting_for_review", idle: "ui.idle",
160
160
  completed: "ui.completed", failed: "ui.problem", cancelled: "ui.stopped",
161
161
  });
162
162
  const VIEW_TITLES = localizedLookup({