omnilane 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/scripts/jobs.sh CHANGED
@@ -2,6 +2,7 @@
2
2
  set -euo pipefail
3
3
  # omnilane background-job helper.
4
4
  # Usage: jobs.sh [--json] list | status JOB_ID | result JOB_ID | stats [--last N]
5
+ # jobs.sh [--json] recommend [--last N] [--lane L] [--min-samples N]
5
6
  # jobs.sh wait JOB_ID [--timeout N]
6
7
  # jobs.sh audit [--last N] [--json] | prune [--keep N] [--apply]
7
8
 
@@ -48,7 +49,7 @@ die() {
48
49
  exit "$rc"
49
50
  }
50
51
 
51
- USAGE_TEXT="usage: jobs.sh [--json] list [--lane L] [--vendor V] [--status running|done]|status ID|result ID|tail ID [--lines N]|retry ID [--background]|stats [--last N] [--lane L] [--vendor V]|wait ID [--timeout N]|cancel ID|rm ID|audit [--last N]|prune [--keep N] [--older-than DAYS] [--apply]|help"
52
+ USAGE_TEXT="usage: jobs.sh [--json] list [--lane L] [--vendor V] [--status running|done]|status ID|result ID|tail ID [--lines N]|retry ID [--background]|stats [--last N] [--lane L] [--vendor V]|recommend [--last N] [--lane L] [--min-samples N]|wait ID [--timeout N]|cancel ID|rm ID|audit [--last N]|prune [--keep N] [--older-than DAYS] [--apply]|help"
52
53
 
53
54
  usage() {
54
55
  die 2 "$USAGE_TEXT"
@@ -168,6 +169,83 @@ json_stat_counts() {
168
169
  printf ']'
169
170
  }
170
171
 
172
+ build_recommendation_rows() {
173
+ local minimum="$1"
174
+ shift
175
+ [[ $# -gt 0 ]] || return 0
176
+ printf '%s\n' "$@" | awk -F '\t' -v minimum="$minimum" '
177
+ {
178
+ key = $1 SUBSEP $2
179
+ samples[key]++
180
+ if ($3 == 0) succeeded[key]++
181
+ else failed[key]++
182
+ }
183
+ END {
184
+ for (key in samples) {
185
+ split(key, parts, SUBSEP)
186
+ rate = int((succeeded[key] * 100) / samples[key])
187
+ eligible = samples[key] >= minimum ? 1 : 0
188
+ printf "%s\t%d\t%d\t%d\t%s\t%d\t%d\n", \
189
+ parts[1], eligible, rate, samples[key], parts[2], \
190
+ succeeded[key] + 0, failed[key] + 0
191
+ }
192
+ }
193
+ ' | LC_ALL=C sort -t $'\t' -k1,1 -k2,2nr -k3,3nr -k4,4nr -k5,5
194
+ }
195
+
196
+ recommendations_json() {
197
+ local rows="$1"
198
+ [[ -n "$rows" ]] || { printf '[]'; return 0; }
199
+ printf '%s\n' "$rows" | awk -F '\t' '
200
+ function close_lane( status, recommendation) {
201
+ if (current == "") return
202
+ status = recommended == "" ? "insufficient_data" : "ready"
203
+ recommendation = recommended == "" ? "null" : "\"" recommended "\""
204
+ printf "],\"status\":\"%s\",\"recommended_vendor\":%s}", status, recommendation
205
+ }
206
+ BEGIN { printf "["; current = ""; first_lane = 1 }
207
+ {
208
+ if ($1 != current) {
209
+ close_lane()
210
+ if (!first_lane) printf ","
211
+ first_lane = 0
212
+ current = $1
213
+ first_candidate = 1
214
+ recommended = ""
215
+ printf "{\"lane\":\"%s\",\"candidates\":[", current
216
+ }
217
+ if (!first_candidate) printf ","
218
+ first_candidate = 0
219
+ eligible = $2 == 1 ? "true" : "false"
220
+ printf "{\"vendor\":\"%s\",\"samples\":%d,\"succeeded\":%d,\"failed\":%d,\"success_rate\":%d,\"eligible\":%s}", \
221
+ $5, $4, $6, $7, $3, eligible
222
+ if (recommended == "" && $2 == 1) recommended = $5
223
+ }
224
+ END { close_lane(); printf "]" }
225
+ '
226
+ }
227
+
228
+ print_recommendations() {
229
+ local rows="$1" minimum="$2"
230
+ [[ -n "$rows" ]] || return 0
231
+ printf '%s\n' "$rows" | awk -F '\t' -v minimum="$minimum" '
232
+ $1 != current {
233
+ current = $1
234
+ if ($2 == 1) {
235
+ printf "recommend lane=%s vendor=%s success_rate=%d%% samples=%d succeeded=%d failed=%d\n", \
236
+ $1, $5, $3, $4, $6, $7
237
+ } else {
238
+ printf "recommend lane=%s status=insufficient_data min_samples=%d\n", $1, minimum
239
+ }
240
+ }
241
+ {
242
+ eligible = $2 == 1 ? "yes" : "no"
243
+ printf "candidate lane=%s vendor=%s success_rate=%d%% samples=%d succeeded=%d failed=%d eligible=%s\n", \
244
+ $1, $5, $3, $4, $6, $7, eligible
245
+ }
246
+ '
247
+ }
248
+
171
249
  path_mode() {
172
250
  if stat -f '%Lp' "$1" >/dev/null 2>&1; then
173
251
  stat -f '%Lp' "$1"
@@ -191,7 +269,7 @@ set -- ${args[@]+"${args[@]}"}
191
269
  COMMAND="${1:-unknown}"
192
270
  if [[ "$JSON_MODE" -eq 1 ]]; then
193
271
  case "$COMMAND" in
194
- list|status|result|stats|audit) ;;
272
+ list|status|result|stats|recommend|audit) ;;
195
273
  *) usage ;;
196
274
  esac
197
275
  fi
@@ -418,6 +496,88 @@ case "${1:-}" in
418
496
  "$sampled" "$passed" "$failed" "$findings"
419
497
  fi
420
498
  [[ "$findings" -eq 0 ]] || exit 1 ;;
499
+ recommend)
500
+ limit=100
501
+ minimum_samples=3
502
+ filter_lane=""
503
+ shift
504
+ while [[ $# -gt 0 ]]; do
505
+ case "$1" in
506
+ --last)
507
+ [[ $# -ge 2 ]] || usage
508
+ limit="$2"; shift 2 ;;
509
+ --lane)
510
+ [[ $# -ge 2 ]] || usage
511
+ filter_lane="$2"; shift 2 ;;
512
+ --min-samples)
513
+ [[ $# -ge 2 ]] || usage
514
+ minimum_samples="$2"; shift 2 ;;
515
+ *) usage ;;
516
+ esac
517
+ done
518
+ [[ "$limit" =~ ^[1-9][0-9]{0,3}$ && "$limit" -le 10000 ]] || {
519
+ die 2 "invalid --last value (want 1..10000)"
520
+ }
521
+ [[ "$minimum_samples" =~ ^[1-9][0-9]{0,2}$ && "$minimum_samples" -le 1000 ]] || {
522
+ die 2 "invalid --min-samples value (want 1..1000)"
523
+ }
524
+ [[ -z "$filter_lane" || "$filter_lane" =~ ^[a-z][a-z0-9-]*$ ]] || die 2 "invalid --lane value"
525
+ sampled=0
526
+ completed=0
527
+ running=0
528
+ invalid_exit=0
529
+ invalid_metadata=0
530
+ ids=()
531
+ outcomes=()
532
+ if [[ -d "$JOBS" ]]; then
533
+ for job_dir in "$JOBS"/*; do
534
+ [[ -d "$job_dir" && ! -L "$job_dir" ]] || continue
535
+ id="${job_dir##*/}"
536
+ [[ "$id" =~ $JOB_ID_PATTERN ]] || continue
537
+ ids+=("$id")
538
+ done
539
+ fi
540
+ if [[ ${#ids[@]} -gt 0 ]]; then
541
+ while IFS= read -r id; do
542
+ job_dir="$JOBS/$id"
543
+ metadata_path="$job_dir/meta.json"
544
+ meta_ok=0
545
+ if read_public_metadata "$metadata_path" &&
546
+ parse_stats_metadata "$PUBLIC_METADATA"; then
547
+ meta_ok=1
548
+ fi
549
+ if [[ -n "$filter_lane" ]]; then
550
+ [[ "$meta_ok" -eq 1 && "$filter_lane" == "$STATS_LANE" ]] || continue
551
+ fi
552
+ sampled=$((sampled + 1))
553
+ if [[ "$meta_ok" -ne 1 ]]; then
554
+ invalid_metadata=$((invalid_metadata + 1))
555
+ continue
556
+ fi
557
+ exit_path="$job_dir/exit"
558
+ if [[ ! -e "$exit_path" && ! -L "$exit_path" ]]; then
559
+ running=$((running + 1))
560
+ continue
561
+ fi
562
+ if ! read_exit_code "$exit_path"; then
563
+ invalid_exit=$((invalid_exit + 1))
564
+ continue
565
+ fi
566
+ completed=$((completed + 1))
567
+ outcomes+=("$STATS_LANE"$'\t'"$STATS_VENDOR"$'\t'"$RECORDED_EXIT")
568
+ done < <(printf '%s\n' "${ids[@]}" | LC_ALL=C sort -r | sed -n "1,${limit}p")
569
+ fi
570
+ rows="$(build_recommendation_rows "$minimum_samples" ${outcomes[@]+"${outcomes[@]}"})"
571
+ if [[ "$JSON_MODE" -eq 1 ]]; then
572
+ recommendations="$(recommendations_json "$rows")"
573
+ printf '{"schema_version":1,"command":"recommend","ok":true,"sampled":%s,"completed":%s,"excluded":{"running":%s,"invalid_exit":%s,"invalid_metadata":%s},"minimum_samples":%s,"recommendations":%s}\n' \
574
+ "$sampled" "$completed" "$running" "$invalid_exit" "$invalid_metadata" \
575
+ "$minimum_samples" "$recommendations"
576
+ else
577
+ printf 'recommendations: sampled=%s completed=%s running=%s invalid_exit=%s invalid_metadata=%s min_samples=%s\n' \
578
+ "$sampled" "$completed" "$running" "$invalid_exit" "$invalid_metadata" "$minimum_samples"
579
+ print_recommendations "$rows" "$minimum_samples"
580
+ fi ;;
421
581
  stats)
422
582
  limit=100
423
583
  filter_lane=""; filter_vendor=""
@@ -0,0 +1,180 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # Explicit, bounded live-inference probe for exactly one configured provider.
5
+ # No provider is contacted unless --vendor is present. Provider output and
6
+ # stderr stay inside a private temporary directory and are never relayed.
7
+
8
+ SELF_REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
9
+ REPO="${OMNILANE_PROBE_REPO:-$SELF_REPO}"
10
+ DISPATCH="$REPO/scripts/dispatch.sh"
11
+ JSON_MODE=0
12
+ VENDOR=""
13
+ TIMEOUT=30
14
+
15
+ usage() {
16
+ echo "usage: provider-probe.sh --vendor V [--timeout SEC] [--json]" >&2
17
+ exit 2
18
+ }
19
+
20
+ json_escape() {
21
+ local s="$1" out="" ch escaped code i
22
+ for ((i = 0; i < ${#s}; i++)); do
23
+ ch="${s:i:1}"
24
+ case "$ch" in
25
+ '"') out="$out\\\"" ;;
26
+ '\\') out="$out\\\\" ;;
27
+ $'\b') out="$out\\b" ;;
28
+ $'\f') out="$out\\f" ;;
29
+ $'\n') out="$out\\n" ;;
30
+ $'\r') out="$out\\r" ;;
31
+ $'\t') out="$out\\t" ;;
32
+ *)
33
+ LC_CTYPE=C printf -v code '%d' "'$ch"
34
+ if [[ "$code" -ge 0 && "$code" -lt 32 ]]; then
35
+ printf -v escaped '\\u%04x' "$code"
36
+ out="$out$escaped"
37
+ else
38
+ out="$out$ch"
39
+ fi
40
+ ;;
41
+ esac
42
+ done
43
+ printf '%s' "$out"
44
+ }
45
+
46
+ emit_result() {
47
+ local ok="$1" status="$2" invoked="$3" reason="$4"
48
+ local duration="$5" response_bytes="$6"
49
+ if [[ "$JSON_MODE" -eq 1 ]]; then
50
+ printf '{"schema_version":1,"command":"provider-probe","ok":%s,"vendor":"%s","model":"%s","status":"%s","provider_invoked":%s,"timeout":%s,"duration_seconds":%s,"response_bytes":%s,"reason":"%s"}\n' \
51
+ "$ok" "$(json_escape "$VENDOR")" "$(json_escape "${MODEL:-}")" \
52
+ "$(json_escape "$status")" "$invoked" "$TIMEOUT" "$duration" \
53
+ "$response_bytes" "$(json_escape "$reason")"
54
+ else
55
+ printf 'provider_probe vendor=%s model=%s status=%s provider_invoked=%s timeout=%ss duration=%ss response_bytes=%s reason=%s\n' \
56
+ "$VENDOR" "${MODEL:-unknown}" "$status" "$invoked" "$TIMEOUT" \
57
+ "$duration" "$response_bytes" "$reason"
58
+ fi
59
+ }
60
+
61
+ while [[ $# -gt 0 ]]; do
62
+ case "$1" in
63
+ --vendor)
64
+ [[ $# -ge 2 ]] || usage
65
+ VENDOR="$2"; shift 2 ;;
66
+ --timeout)
67
+ [[ $# -ge 2 ]] || usage
68
+ TIMEOUT="$2"; shift 2 ;;
69
+ --json)
70
+ JSON_MODE=1; shift ;;
71
+ *) usage ;;
72
+ esac
73
+ done
74
+
75
+ [[ "$VENDOR" =~ ^(codex|claude|grok|gemini|kimi|qwen|opencode|openrouter|deepseek|zai|mistral|groq|cerebras)$ ]] || {
76
+ echo "omnilane: invalid vendor for provider probe" >&2
77
+ exit 2
78
+ }
79
+ [[ "$TIMEOUT" =~ ^[1-9][0-9]{0,2}$ && "$TIMEOUT" -le 300 ]] || {
80
+ echo "omnilane: invalid probe timeout (want 1..300 seconds)" >&2
81
+ exit 2
82
+ }
83
+ [[ -x "$DISPATCH" ]] || {
84
+ emit_result false unavailable false "dispatch is unavailable" 0 0
85
+ exit 1
86
+ }
87
+
88
+ PLAN=""
89
+ while IFS= read -r lane; do
90
+ [[ "$lane" =~ ^[a-z][a-z0-9-]*$ ]] || continue
91
+ set +e
92
+ candidate="$(bash "$DISPATCH" --dry-run --mode advise --vendor "$VENDOR" \
93
+ --timeout "$TIMEOUT" "$lane" "OMNILANE_PROVIDER_PROBE" 2>/dev/null)"
94
+ candidate_rc=$?
95
+ set -e
96
+ if [[ "$candidate_rc" -eq 0 && "$candidate" == *"provider_invoked=no"* ]]; then
97
+ PLAN="$candidate"
98
+ break
99
+ fi
100
+ done < <(bash "$DISPATCH" --list 2>/dev/null | sed -n 's/^\([a-z][a-z0-9-]*\):.*/\1/p')
101
+
102
+ if [[ -z "$PLAN" ]]; then
103
+ emit_result false unavailable false "vendor is not both configured and available" 0 0
104
+ exit 4
105
+ fi
106
+
107
+ field_value() {
108
+ local key="$1"
109
+ printf '%s\n' "$PLAN" | sed -n "s/^${key}=//p" | sed -n '1p'
110
+ }
111
+
112
+ decode_plan_value() {
113
+ local encoded="$1" decoded
114
+ [[ ${#encoded} -le 4096 && "$encoded" != *$'\n'* ]] || return 1
115
+ # dispatch.sh emits values with Bash printf %q. read without -r reverses
116
+ # its backslash quoting without evaluating command substitutions or code.
117
+ IFS= read decoded <<< "$encoded" || return 1
118
+ printf '%s' "$decoded"
119
+ }
120
+
121
+ RAW_MODEL="$(field_value model)"
122
+ RAW_EFFORT="$(field_value effort)"
123
+ if ! MODEL="$(decode_plan_value "$RAW_MODEL")" ||
124
+ ! EFFORT="$(decode_plan_value "$RAW_EFFORT")"; then
125
+ emit_result false unavailable false "invalid dry-run field encoding" 0 0
126
+ exit 4
127
+ fi
128
+ RUNNER="$REPO/scripts/runners/run-$VENDOR.sh"
129
+ [[ -n "$MODEL" && -x "$RUNNER" ]] || {
130
+ emit_result false unavailable false "resolved runner or model is unavailable" 0 0
131
+ exit 4
132
+ }
133
+
134
+ TMP_BASE="${TMPDIR:-/tmp}"
135
+ TMP_ROOT="$(mktemp -d "$TMP_BASE/omnilane-provider-probe.XXXXXX")"
136
+ cleanup() {
137
+ local path
138
+ case "${TMP_ROOT:-}" in
139
+ "$TMP_BASE"/omnilane-provider-probe.*)
140
+ if [[ -d "$TMP_ROOT" && ! -L "$TMP_ROOT" ]]; then
141
+ for path in "$TMP_ROOT"/* "$TMP_ROOT"/.[!.]*; do
142
+ [[ -f "$path" || -L "$path" ]] && rm -- "$path"
143
+ done
144
+ rmdir "$TMP_ROOT" 2>/dev/null || true
145
+ fi
146
+ ;;
147
+ esac
148
+ }
149
+ trap cleanup EXIT HUP INT TERM
150
+ PROMPT_FILE="$TMP_ROOT/prompt.txt"
151
+ OUTPUT_FILE="$TMP_ROOT/output.txt"
152
+ (umask 077; printf '%s\n' \
153
+ 'Reply with exactly OMNILANE_PROVIDER_PROBE_OK. Do not use tools or inspect files.' \
154
+ > "$PROMPT_FILE")
155
+
156
+ started="$(date +%s)"
157
+ set +e
158
+ OMNILANE_TIMEOUT="$TIMEOUT" "$RUNNER" advise \
159
+ "${OMNILANE_PROBE_WORKDIR:-$PWD}" "$MODEL" "$EFFORT" \
160
+ "$PROMPT_FILE" "$OUTPUT_FILE" >/dev/null 2>&1
161
+ runner_rc=$?
162
+ set -e
163
+ ended="$(date +%s)"
164
+ duration=$((ended - started))
165
+ response_bytes=0
166
+ if [[ -f "$OUTPUT_FILE" && ! -L "$OUTPUT_FILE" ]]; then
167
+ response_bytes="$(wc -c < "$OUTPUT_FILE" | tr -d '[:space:]')"
168
+ [[ "$response_bytes" =~ ^[0-9]+$ ]] || response_bytes=0
169
+ fi
170
+
171
+ if [[ "$runner_rc" -eq 0 && "$response_bytes" -gt 0 ]]; then
172
+ emit_result true usable true "bounded live inference returned output" "$duration" "$response_bytes"
173
+ exit 0
174
+ fi
175
+ if [[ "$runner_rc" -eq 124 || "$runner_rc" -eq 142 ]]; then
176
+ emit_result false timeout true "bounded live inference timed out" "$duration" "$response_bytes"
177
+ else
178
+ emit_result false failed true "runner exited with status $runner_rc" "$duration" "$response_bytes"
179
+ fi
180
+ exit 1
package/ui/app.js CHANGED
@@ -21,6 +21,7 @@
21
21
  connectionLabel: document.getElementById("connection-label"),
22
22
  languageSelect: document.getElementById("language-select"),
23
23
  jobCount: document.getElementById("job-count"), search: document.getElementById("job-search"),
24
+ exportVisible: document.getElementById("export-visible"),
24
25
  filter: document.getElementById("status-filter"),
25
26
  filterButtons: Array.from(document.querySelectorAll(".filter-button")),
26
27
  jobList: document.getElementById("job-list"), listMessage: document.getElementById("list-message"),
@@ -296,6 +297,7 @@
296
297
 
297
298
  function setControlsDisabled(disabled) {
298
299
  elements.search.disabled = disabled;
300
+ elements.exportVisible.disabled = disabled;
299
301
  elements.filterButtons.forEach(function (button) {
300
302
  button.disabled = disabled;
301
303
  });
@@ -471,6 +473,52 @@
471
473
  });
472
474
  }
473
475
 
476
+ function exportField(meta, key) {
477
+ return typeof meta[key] === "string" && meta[key].length > 0 ? meta[key] : null;
478
+ }
479
+
480
+ function exportVisibleJobs() {
481
+ const jobs = visibleJobs();
482
+ if (jobs.length === 0) {
483
+ return;
484
+ }
485
+ const exportedAt = new Date();
486
+ const payload = {
487
+ schema_version: 1,
488
+ exported_at: exportedAt.toISOString(),
489
+ query: state.query,
490
+ filter: state.filter,
491
+ jobs: jobs.map(function (job) {
492
+ const meta = job.meta;
493
+ return {
494
+ id: job.id,
495
+ state: job.state,
496
+ exit_code: job.exitCode,
497
+ lane: exportField(meta, "lane"),
498
+ vendor: exportField(meta, "vendor"),
499
+ model: exportField(meta, "model"),
500
+ effort: exportField(meta, "effort"),
501
+ mode: exportField(meta, "mode"),
502
+ candidate: exportField(meta, "candidate"),
503
+ started: exportField(meta, "started"),
504
+ timeout: Number.isInteger(meta.timeout) ? meta.timeout : null,
505
+ };
506
+ }),
507
+ };
508
+ const blob = new Blob([JSON.stringify(payload, null, 2) + "\n"], {
509
+ type: "application/json;charset=utf-8",
510
+ });
511
+ const url = URL.createObjectURL(blob);
512
+ const stamp = exportedAt.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
513
+ const anchor = document.createElement("a");
514
+ anchor.href = url;
515
+ anchor.download = "omnilane-jobs-" + stamp + ".json";
516
+ document.body.appendChild(anchor);
517
+ anchor.click();
518
+ anchor.remove();
519
+ window.setTimeout(function () { URL.revokeObjectURL(url); }, 0);
520
+ }
521
+
474
522
  function routeLabel(job) {
475
523
  const meta = job.meta;
476
524
  return [
@@ -525,6 +573,7 @@
525
573
 
526
574
  function renderQueue() {
527
575
  const jobs = visibleJobs();
576
+ elements.exportVisible.disabled = state.unauthorized || jobs.length === 0;
528
577
  const listScroll = elements.jobList.scrollTop;
529
578
  const inspectorScroll = elements.inspector.scrollTop;
530
579
  const focusedCard = document.activeElement && document.activeElement.closest
@@ -1270,6 +1319,8 @@
1270
1319
  updateFilterSelection();
1271
1320
  });
1272
1321
 
1322
+ elements.exportVisible.addEventListener("click", exportVisibleJobs);
1323
+
1273
1324
  elements.mobileBack.addEventListener("click", function () {
1274
1325
  returnToMobileList(true);
1275
1326
  });
@@ -1327,6 +1378,7 @@
1327
1378
  "filter.active": "Active",
1328
1379
  "filter.done": "Done",
1329
1380
  "filter.issues": "Issues",
1381
+ "export.visible": "Export visible",
1330
1382
  "list.ariaLabel": "Recent tasks",
1331
1383
  "list.waiting": "Waiting for tasks.",
1332
1384
  "list.empty": "No tasks yet.",
@@ -1438,6 +1490,7 @@
1438
1490
  "filter.active": "実行中",
1439
1491
  "filter.done": "完了",
1440
1492
  "filter.issues": "問題あり",
1493
+ "export.visible": "表示中をエクスポート",
1441
1494
  "list.ariaLabel": "最近のタスク",
1442
1495
  "list.waiting": "タスクを待機しています。",
1443
1496
  "list.empty": "タスクはまだありません。",
@@ -1549,6 +1602,7 @@
1549
1602
  "filter.active": "진행 중",
1550
1603
  "filter.done": "완료",
1551
1604
  "filter.issues": "문제",
1605
+ "export.visible": "표시 항목 내보내기",
1552
1606
  "list.ariaLabel": "최근 작업",
1553
1607
  "list.waiting": "작업을 기다리는 중입니다.",
1554
1608
  "list.empty": "아직 작업이 없습니다.",
@@ -1660,6 +1714,7 @@
1660
1714
  "filter.active": "進行中",
1661
1715
  "filter.done": "完成",
1662
1716
  "filter.issues": "有問題",
1717
+ "export.visible": "匯出目前結果",
1663
1718
  "list.ariaLabel": "最近的任務",
1664
1719
  "list.waiting": "等待任務中。",
1665
1720
  "list.empty": "尚無任務。",
@@ -1771,6 +1826,7 @@
1771
1826
  "filter.active": "进行中",
1772
1827
  "filter.done": "完成",
1773
1828
  "filter.issues": "有问题",
1829
+ "export.visible": "导出当前结果",
1774
1830
  "list.ariaLabel": "最近的任务",
1775
1831
  "list.waiting": "等待任务中。",
1776
1832
  "list.empty": "暂无任务。",
package/ui/index.html CHANGED
@@ -39,6 +39,7 @@
39
39
  <p class="section-label" data-i18n="index.sectionLabel">Recent work</p>
40
40
  <h1 id="dispatch-index-title" data-i18n="index.title">Task record</h1>
41
41
  </div>
42
+ <button type="button" class="export-button" id="export-visible" data-i18n="export.visible">Export visible</button>
42
43
  </div>
43
44
 
44
45
  <div class="queue-controls">
package/ui/styles.css CHANGED
@@ -210,6 +210,28 @@ summary:focus-visible,
210
210
  border-bottom: 1px solid var(--line);
211
211
  }
212
212
 
213
+ .export-button {
214
+ min-height: 36px;
215
+ margin-left: auto;
216
+ border: 1px solid var(--line-strong);
217
+ border-radius: 4px;
218
+ padding: 0 10px;
219
+ background: var(--paper);
220
+ color: var(--muted);
221
+ font-size: 12px;
222
+ font-weight: 650;
223
+ }
224
+
225
+ .export-button:hover:not(:disabled) {
226
+ color: var(--ink);
227
+ background: #edf0f2;
228
+ }
229
+
230
+ .export-button:disabled {
231
+ cursor: not-allowed;
232
+ opacity: 0.5;
233
+ }
234
+
213
235
  .section-label,
214
236
  .state-code {
215
237
  margin: 0 0 3px;