zigmini 0.8.0-beta.4 → 0.8.0-beta.6

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/web/app.js CHANGED
@@ -26,7 +26,9 @@ import {
26
26
  import { agentIconUrl, editAgentIcon, readAgentIcon, writeAgentIcon } from "./agent-icon.js";
27
27
  import { hasTextSelection, panelShortcut } from "./shortcuts.js";
28
28
  import { decisionCommand, lastSentLabel, lastSentTooltip, nextLastSent } from "./lastsent.js";
29
+ import { installTooltips } from "./tooltip.js";
29
30
  import { placeRow, unseenEvents } from "./chatorder.js";
31
+ import { cardsOfDecision } from "./cardbind.js";
30
32
  import { createRecall, recallKey, resetRecall } from "./recall.js";
31
33
  import {
32
34
  addReference, clearReferences, createReferences, inputData, markReferenceable, planSend, referenceSeqs, referenceSource, removeReference,
@@ -40,9 +42,11 @@ import {
40
42
  import { createEnrollment, NO_DEVICES_LINE, REVOKE_ARMED_LABEL, REVOKE_LABEL } from "./enroll.js";
41
43
  import { canUpdate, checkFailedLine, noticeLine, parseNotice, progressLine, questionGate, resultLine, UPDATE_CHECK_PATH, UPDATE_CONSENT_PATH, UPDATE_PATH, UPDATE_STATE_PATH, UPDATE_STATUS_PATH, updateStep, updateView } from "./update.js";
42
44
  import { createAppDownload } from "./app-download.js";
43
- import { cleanRequest, cleanResultLine, GC_CLEAN_PATH, GC_PATH, gcView } from "./gc.js";
45
+ import { cleanRequest, cleanResultLine, GC_CLEAN_PATH, GC_PATH, gcView, selectAllState, selectAllTarget } from "./gc.js";
44
46
  import { manualFailureText, manualLinkLang, manualUrl, otherLang } from "./manual.js";
45
47
 
48
+ installTooltips(document, window);
49
+
46
50
  const $ = (id) => document.getElementById(id);
47
51
  const state = {
48
52
  conversations: [],
@@ -676,12 +680,9 @@ function cardForDecision(id) {
676
680
  return els.messages.querySelector(`.approval[data-decision-id="${CSS.escape(id)}"]`);
677
681
  }
678
682
 
679
- /** The mounted card of a decision: its explicit identity first, then the original action alias of an id-less card. */
680
- function cardOf(decision) {
681
- const explicit = cardForDecision(decision.id);
682
- if (explicit) return explicit;
683
- const card = cardFor(originalActionId(decision));
684
- return card && (card.dataset.decisionId === undefined || card.dataset.decisionId === decision.id) ? card : null;
683
+ /** Every mounted card of a decision; a rebound question keeps its decision id on a new card (bug 154). */
684
+ function cardsOf(decision) {
685
+ return cardsOfDecision(els.messages.querySelectorAll(".approval"), decision.id, originalActionId(decision));
685
686
  }
686
687
 
687
688
  /** Legacy or still-loading card bodies: controls on or off plus one status line, optionally with a retry control. */
@@ -715,13 +716,14 @@ function setCardBusy(actionId, busy) {
715
716
  }
716
717
 
717
718
  /** Re-renders the mounted card of a decision from canonical state; an id-less card follows only its own unique alias. */
718
- function syncCard(decision) {
719
- const card = cardOf(decision);
720
- if (!card) return;
721
- const request = cardRequests.get(card);
722
- if (!request) return;
723
- if (typeof request.decisionId !== "string" && decisionForAction(inbox.state, request.actionId)?.id !== decision.id) return;
724
- renderCardBody(card, decision);
719
+ function syncCard(decision, except = null) {
720
+ for (const card of cardsOf(decision)) {
721
+ if (card === except) continue;
722
+ const request = cardRequests.get(card);
723
+ if (!request) continue;
724
+ if (typeof request.decisionId !== "string" && decisionForAction(inbox.state, request.actionId)?.id !== decision.id) continue;
725
+ renderCardBody(card, decision);
726
+ }
725
727
  }
726
728
 
727
729
  /** One line of state under a canonical card: in transit, locked for review, offline, finished, or why it still waits. */
@@ -1556,7 +1558,7 @@ function renderOmo() {
1556
1558
  if (omoStatus === null) return;
1557
1559
  const ready = omoStatus === "ready";
1558
1560
  const checking = ready && state.ws?.readyState === WebSocket.OPEN && isUnknown(activity, state.current);
1559
- els.omo.textContent = checking ? "작업 상태 확인 중" : ready ? "omo 연결됨" : omoStatus === "connecting" ? "omo 연결 중" : "omo 끊김";
1561
+ els.omo.textContent = checking ? "작업 상태 확인 중" : ready ? "엔진 연결됨" : omoStatus === "connecting" ? "엔진 연결 중" : "엔진 끊김";
1560
1562
  els.omo.classList.toggle("ok", ready);
1561
1563
  }
1562
1564
 
@@ -1581,7 +1583,7 @@ function renderModels(info) {
1581
1583
  modelSelect.append(group);
1582
1584
  }
1583
1585
  if (info.current) modelSelect.value = `${info.current.provider}/${info.current.id}`;
1584
- modelSelect.title = info.current ? `omo 모델: ${info.current.name}` : "omo 모델";
1586
+ modelSelect.title = info.current ? `엔진 모델: ${info.current.name}` : "엔진 모델";
1585
1587
  // A held switch stays on the current model until the next message compacts the context for the target.
1586
1588
  const pending = info.pending ?? null;
1587
1589
  modelPending.hidden = !pending;
@@ -2027,7 +2029,7 @@ function renderSlashMenu() {
2027
2029
  });
2028
2030
  return row;
2029
2031
  });
2030
- const note = slash.loading ? "omo 명령과 스킬을 불러오는 중…" : slash.failed ? "omo 명령과 스킬을 불러오지 못했습니다. 웹 지그 명령만 보입니다." : rows.length === 0 ? "맞는 명령이나 스킬이 없습니다." : "";
2032
+ const note = slash.loading ? "엔진 명령과 스킬을 불러오는 중…" : slash.failed ? "엔진 명령과 스킬을 불러오지 못했습니다. 웹 지그 명령만 보입니다." : rows.length === 0 ? "맞는 명령이나 스킬이 없습니다." : "";
2031
2033
  if (note) {
2032
2034
  const div = document.createElement("div");
2033
2035
  div.className = "slash-note";
@@ -2110,7 +2112,7 @@ $("new-conv").onclick = () => {
2110
2112
  };
2111
2113
  $("interrupt").onclick = () => send({ type: "input.interrupt", conv: state.current, data: {} });
2112
2114
  $("rotate-session").onclick = () => {
2113
- if (!confirm("이 대화의 omo 세션을 새로 시작할까요?\n화면의 기록은 그대로 남고, 최근 대화 일부를 새 세션에 넘겨 줍니다.")) return;
2115
+ if (!confirm("이 대화의 엔진 세션을 새로 시작할까요?\n화면의 기록은 그대로 남고, 최근 대화 일부를 새 세션에 넘겨 줍니다.")) return;
2114
2116
  send({ type: "conversation.rotate", conv: state.current });
2115
2117
  };
2116
2118
  function toggleDevPanel() {
@@ -2583,6 +2585,12 @@ async function fetchDecisions(cursor) {
2583
2585
  }
2584
2586
 
2585
2587
  /** `refresh`: load the current copy even though one is cached (a mounted card the reset page no longer lists). */
2588
+ /** The mounted cards a detail load reports to: every card of the decision id, so a second card never waits on the first. */
2589
+ function detailCards(id, card) {
2590
+ const cards = [...els.messages.querySelectorAll(`.approval[data-decision-id="${CSS.escape(id)}"]`)];
2591
+ return card?.isConnected && !cards.includes(card) ? [...cards, card] : cards;
2592
+ }
2593
+
2586
2594
  async function loadDecisionDetail(id, card, { refresh = false } = {}) {
2587
2595
  const generation = decisionGeneration;
2588
2596
  if ((!refresh && decisionById(inbox.state, id)) || !inbox.online || !beginDetailLoad(inbox.state, id, generation)) return;
@@ -2592,19 +2600,19 @@ async function loadDecisionDetail(id, card, { refresh = false } = {}) {
2592
2600
  const body = await res.json().catch(() => null);
2593
2601
  if (!res.ok) {
2594
2602
  if (!failDetailLoad(inbox.state, id, generation)) return;
2595
- if (card?.isConnected) setCardState(card, { disabled: true, note: `결정 상태를 불러오지 못했습니다 (${res.status}).`, retry });
2603
+ for (const shown of detailCards(id, card)) setCardState(shown, { disabled: true, note: `결정 상태를 불러오지 못했습니다 (${res.status}).`, retry });
2596
2604
  return;
2597
2605
  }
2598
2606
  const result = finishDetailLoad(inbox.state, id, generation, body);
2599
2607
  if (!result.accepted) {
2600
- if (card?.isConnected && result.reason === "mismatch") setCardState(card, { disabled: true, note: "결정 식별자가 일치하지 않습니다.", retry });
2608
+ if (result.reason === "mismatch") for (const shown of detailCards(id, card)) setCardState(shown, { disabled: true, note: "결정 식별자가 일치하지 않습니다.", retry });
2601
2609
  return;
2602
2610
  }
2603
2611
  const decision = decisionById(inbox.state, id);
2604
2612
  if (decision) syncCard(decision);
2605
2613
  } catch (err) {
2606
2614
  if (!failDetailLoad(inbox.state, id, generation)) return;
2607
- if (card?.isConnected) setCardState(card, {
2615
+ for (const shown of detailCards(id, card)) setCardState(shown, {
2608
2616
  disabled: true,
2609
2617
  note: `결정 상태를 불러오지 못했습니다: ${err instanceof Error ? err.message : String(err)}`,
2610
2618
  retry,
@@ -2938,8 +2946,7 @@ function reviewLatest(decision) {
2938
2946
 
2939
2947
  /** Both surfaces render the same canonical decision; the surface being edited keeps its focus. */
2940
2948
  function syncDecisionSurfaces(decision, except = null) {
2941
- const card = cardOf(decision);
2942
- if (card && card !== except) syncCard(decision);
2949
+ syncCard(decision, except);
2943
2950
  if (inbox.state.items.has(decision.id) && inbox.nodes.get(decision.id) !== except) renderInboxItem(decision);
2944
2951
  }
2945
2952
 
@@ -3175,8 +3182,7 @@ function settleSubmit(decision, result) {
3175
3182
  break;
3176
3183
  case "gone": {
3177
3184
  forgetDecision(inbox.state, id);
3178
- const card = cardForDecision(id);
3179
- if (card) {
3185
+ for (const card of els.messages.querySelectorAll(`.approval[data-decision-id="${CSS.escape(id)}"]`)) {
3180
3186
  card.removeAttribute("aria-busy");
3181
3187
  setCardState(card, { disabled: true, note: "서버에 더 이상 없는 요청입니다." });
3182
3188
  }
@@ -3416,14 +3422,23 @@ async function checkUpdateNow() {
3416
3422
  else showUpdateError(checkFailedLine(res?.status ?? 0));
3417
3423
  }
3418
3424
 
3425
+ // The version this page first loaded on: a stored ok result that ended there is stale after a reload (live update.result is not filtered).
3426
+ let pageVersion = null;
3427
+
3419
3428
  function loadUpdateStatus() {
3420
3429
  fetch(UPDATE_STATUS_PATH)
3421
3430
  .then((res) => (res.ok ? res.json() : null))
3422
- .then(renderUpdate)
3423
- .catch((error) => console.warn("update status load:", error));
3424
- fetch(UPDATE_STATE_PATH)
3431
+ .catch((error) => {
3432
+ console.warn("update status load:", error);
3433
+ return null;
3434
+ })
3435
+ .then((status) => {
3436
+ renderUpdate(status);
3437
+ if (pageVersion === null && typeof status?.current === "string") pageVersion = status.current;
3438
+ return fetch(UPDATE_STATE_PATH);
3439
+ })
3425
3440
  .then((res) => (res.ok ? res.json() : null))
3426
- .then((state) => showUpdateProgress(progressLine(state)))
3441
+ .then((state) => showUpdateProgress(progressLine(state, pageVersion)))
3427
3442
  .catch((error) => console.warn("update state load:", error));
3428
3443
  }
3429
3444
 
@@ -3577,7 +3592,28 @@ const appDownload = createAppDownload({
3577
3592
  apkEls.renew.onclick = () => void appDownload.renew();
3578
3593
 
3579
3594
  // 정리 (improvement 66): nothing is cleaned until the user ticks rows and presses the button.
3580
- const gcEls = { summary: $("gc-summary"), list: $("gc-list"), message: $("gc-message"), clean: $("gc-clean") };
3595
+ const gcEls = { summary: $("gc-summary"), list: $("gc-list"), message: $("gc-message"), clean: $("gc-clean"), all: $("gc-all"), allLabel: $("gc-all-label") };
3596
+
3597
+ function gcBoxes() {
3598
+ return [...gcEls.list.querySelectorAll("input[type=checkbox]")];
3599
+ }
3600
+
3601
+ // Improvement 142: the 전체 선택 box and the clean button follow the ticked rows.
3602
+ function gcSync() {
3603
+ const ticked = gcTicked().length;
3604
+ const state = selectAllState(ticked, gcBoxes().length);
3605
+ gcEls.allLabel.hidden = state.hidden;
3606
+ gcEls.all.checked = state.checked;
3607
+ gcEls.all.indeterminate = state.indeterminate;
3608
+ gcEls.clean.disabled = ticked === 0;
3609
+ }
3610
+
3611
+ gcEls.all.onchange = () => {
3612
+ const boxes = gcBoxes();
3613
+ const tick = selectAllTarget(gcTicked().length, boxes.length);
3614
+ for (const box of boxes) box.checked = tick;
3615
+ gcSync();
3616
+ };
3581
3617
 
3582
3618
  function gcTicked() {
3583
3619
  return [...gcEls.list.querySelectorAll("input[type=checkbox]:checked")].map((box) => box.value);
@@ -3592,7 +3628,7 @@ function gcRow(row) {
3592
3628
  const box = document.createElement("input");
3593
3629
  box.type = "checkbox";
3594
3630
  box.value = row.id;
3595
- box.onchange = () => { gcEls.clean.disabled = gcTicked().length === 0; };
3631
+ box.onchange = gcSync;
3596
3632
  const name = document.createElement("span");
3597
3633
  name.className = "gc-name";
3598
3634
  name.textContent = row.label;
@@ -3619,6 +3655,7 @@ async function loadGc() {
3619
3655
  gcEls.summary.textContent = view.summary;
3620
3656
  gcEls.summary.classList.toggle("error", view.error);
3621
3657
  gcEls.list.replaceChildren(...view.rows.map(gcRow));
3658
+ gcSync();
3622
3659
  }
3623
3660
 
3624
3661
  gcEls.clean.onclick = async () => {
@@ -0,0 +1,3 @@
1
+ export function cardsOfDecision<T extends { readonly dataset: { decisionId?: string; actionId?: string } }>(
2
+ cards: Iterable<T>, decisionId: string, aliasActionId: string,
3
+ ): T[];
@@ -0,0 +1,15 @@
1
+ // Which chat cards show one decision (bug 154). A question that is rebound (for example after a session change) gets a
2
+ // new card that keeps the same decision id, so one decision can own several cards. Every one of them must follow the
3
+ // canonical decision; updating only the first left the others open or stuck at "확인 중…" after the answer was delivered.
4
+
5
+ /**
6
+ * Every mounted card of a decision: the cards that carry its explicit id, else the id-less cards of its original action
7
+ * alias. A card bound to another decision never matches.
8
+ */
9
+ export function cardsOfDecision(cards, decisionId, aliasActionId) {
10
+ const list = [...cards];
11
+ const explicit = list.filter((card) => card.dataset.decisionId === decisionId);
12
+ if (explicit.length > 0) return explicit;
13
+ return list.filter((card) => card.dataset.actionId === aliasActionId &&
14
+ (card.dataset.decisionId === undefined || card.dataset.decisionId === decisionId));
15
+ }
package/web/devlog.js CHANGED
@@ -71,9 +71,9 @@ function summary(type, d = {}) {
71
71
  case "notice":
72
72
  return clip(d.message);
73
73
  case "omo.status":
74
- return clip(`omo ${d.status}${d.detail ? ` · ${d.detail}` : ""}`);
74
+ return clip(`엔진 ${d.status}${d.detail ? ` · ${d.detail}` : ""}`);
75
75
  case "state.snapshot":
76
- return `대화 ${d.conversations?.length ?? 0} · 승인 대기 ${d.approvals?.length ?? 0} · omo ${d.omo}`;
76
+ return `대화 ${d.conversations?.length ?? 0} · 승인 대기 ${d.approvals?.length ?? 0} · 엔진 ${d.omo}`;
77
77
  case "history":
78
78
  return `${d.conv} 기록 ${d.events?.length ?? 0}건`;
79
79
  case "conversation.created":
package/web/gc.d.ts CHANGED
@@ -9,5 +9,7 @@ export const GC_FAILED_LINE: string;
9
9
  export function formatBytes(bytes: unknown): string;
10
10
  export function formatAge(ms: number): string;
11
11
  export function gcView(report: unknown): { summary: string; rows: GcRow[]; error: boolean };
12
+ export function selectAllState(ticked: number, total: number): { hidden: boolean; checked: boolean; indeterminate: boolean };
13
+ export function selectAllTarget(ticked: number, total: number): boolean;
12
14
  export function cleanRequest(ids: Iterable<string>): { method: "POST"; headers: { "content-type": "application/json" }; body: string };
13
15
  export function cleanResultLine(result: unknown): string;
package/web/gc.js CHANGED
@@ -5,7 +5,7 @@ export const GC_PATH = "/api/gc";
5
5
  export const GC_CLEAN_PATH = "/api/gc/clean";
6
6
  export const KIND_LABELS = Object.freeze({
7
7
  "db-backup": "DB 백업",
8
- "omo-version": "OMO 버전 폴더",
8
+ "omo-version": "엔진 버전 폴더",
9
9
  "update-file": "업데이트 남은 파일",
10
10
  "temp-dir": "임시 폴더",
11
11
  "rpc-daemon": "이전 RPC 데몬",
@@ -39,6 +39,17 @@ export function gcView(report) {
39
39
  return { summary, rows, error: false };
40
40
  }
41
41
 
42
+ // Improvement 142: the 전체 선택 box beside the summary. It is hidden with no rows, checked when every row is ticked and
43
+ // indeterminate when only some are.
44
+ export function selectAllState(ticked, total) {
45
+ return { hidden: total === 0, checked: total > 0 && ticked === total, indeterminate: ticked > 0 && ticked < total };
46
+ }
47
+
48
+ // A click on 전체 선택 ticks every row unless every row was already ticked; then it clears them all.
49
+ export function selectAllTarget(ticked, total) {
50
+ return ticked < total;
51
+ }
52
+
42
53
  export function cleanRequest(ids) {
43
54
  return { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ids: [...ids] }) };
44
55
  }
package/web/index.html CHANGED
@@ -11,7 +11,7 @@
11
11
  <aside class="sidebar">
12
12
  <header class="sidebar-head">
13
13
  <span class="sidebar-title">zigmini</span>
14
- <span class="badge" id="omo-status" title="omo app-server 연결 상태">omo</span>
14
+ <span class="badge" id="omo-status" title="엔진 연결 상태">엔진</span>
15
15
  </header>
16
16
  <div class="sidebar-section">
17
17
  <div class="section-label">
@@ -60,16 +60,16 @@
60
60
  <span class="login-notice" id="schema-notice" role="alert" hidden></span>
61
61
  <button type="button" class="update-notice" id="update-notice" title="설정의 업데이트 열기" hidden></button>
62
62
  <div class="chat-actions">
63
- <select class="model-select" id="model-select" title="omo가 쓸 모델" disabled><option>모델 불러오는 중</option></select>
63
+ <select class="model-select" id="model-select" title="엔진이 쓸 모델" disabled><option>모델 불러오는 중</option></select>
64
64
  <span class="model-pending" id="model-pending" hidden></span>
65
- <button class="text-btn" id="rotate-session" title="이 대화의 omo 세션을 새로 시작합니다. 화면의 기록은 그대로 남습니다.">새 세션</button>
66
- <button class="text-btn" id="toggle-voice" title="이 게이트웨이의 음성 서버(음성 인식·음성 합성)를 켜고 끄기. 끄면 마이크·호출어·박수 감지·음성 답변을 쓸 수 없습니다." aria-pressed="true">음성 켜짐</button>
67
- <button class="text-btn" id="voice-install" title="음성 서버(uv, Python 환경, 음성 모델)를 이 맥의 zigmini 폴더에 내려받아 설치합니다" hidden>음성 설치</button>
68
- <button class="text-btn" id="toggle-clap" title="박수를 두 번 치면 호출어 대기를 켜고 끄기" aria-pressed="false">박수 감지 꺼짐</button>
69
- <button class="text-btn" id="toggle-wake" title="&quot;헤이 지그&quot;라고 부르면 받아 적어 입력창에 채우기" aria-pressed="false">호출어 꺼짐</button>
70
- <button class="text-btn" id="toggle-tts" title="답변을 음성으로 읽기" aria-pressed="false">음성 답변 꺼짐</button>
71
- <button type="button" class="text-btn inbox-toggle" id="toggle-inbox" title="답을 기다리는 질문과 승인 요청 (Ctrl+C)" aria-haspopup="dialog" aria-expanded="false" aria-controls="inbox" aria-keyshortcuts="Control+C">확인함<span class="inbox-badge" id="inbox-badge" hidden>0</span></button>
72
- <button type="button" class="text-btn" id="toggle-dev" title="이벤트 패널 (Ctrl+E)" aria-expanded="false" aria-controls="dev" aria-keyshortcuts="Control+E">이벤트</button>
65
+ <button class="text-btn" id="rotate-session" data-tip-keep title="이 대화의 엔진 세션을 새로 시작합니다. 화면의 기록은 그대로 남습니다.">새 세션</button>
66
+ <button class="text-btn" id="toggle-voice" data-tip-keep title="이 게이트웨이의 음성 서버(음성 인식·음성 합성)를 켜고 끄기. 끄면 마이크·호출어·박수 감지·음성 답변을 쓸 수 없습니다." aria-pressed="true">음성 켜짐</button>
67
+ <button class="text-btn" id="voice-install" data-tip-keep title="음성 서버(uv, Python 환경, 음성 모델)를 이 맥의 zigmini 폴더에 내려받아 설치합니다" hidden>음성 설치</button>
68
+ <button class="text-btn" id="toggle-clap" data-tip-keep title="박수를 두 번 치면 호출어 대기를 켜고 끄기" aria-pressed="false">박수 감지 꺼짐</button>
69
+ <button class="text-btn" id="toggle-wake" data-tip-keep title="&quot;헤이 지그&quot;라고 부르면 받아 적어 입력창에 채우기" aria-pressed="false">호출어 꺼짐</button>
70
+ <button class="text-btn" id="toggle-tts" data-tip-keep title="답변을 음성으로 읽기" aria-pressed="false">음성 답변 꺼짐</button>
71
+ <button type="button" class="text-btn inbox-toggle" id="toggle-inbox" data-tip-keep title="답을 기다리는 질문과 승인 요청 (Ctrl+C)" aria-haspopup="dialog" aria-expanded="false" aria-controls="inbox" aria-keyshortcuts="Control+C">확인함<span class="inbox-badge" id="inbox-badge" hidden>0</span></button>
72
+ <button type="button" class="text-btn" id="toggle-dev" data-tip-keep title="이벤트 패널 (Ctrl+E)" aria-expanded="false" aria-controls="dev" aria-keyshortcuts="Control+E">이벤트</button>
73
73
  <button type="button" class="text-btn" id="toggle-settings" title="설정: 폰 기기 등록 QR과 등록된 기기" aria-haspopup="dialog" aria-expanded="false" aria-controls="settings">설정</button>
74
74
  </div>
75
75
  </header>
@@ -100,7 +100,7 @@
100
100
  <button type="button" class="mic" id="toggle-memo" title="쪽지 열기/닫기" aria-pressed="false">쪽지</button>
101
101
  <button type="button" class="mic" id="attach" title="이미지 첨부 (최대 4장 · 붙여넣기나 끌어놓기도 됩니다)">이미지</button>
102
102
  <input type="file" id="attach-input" accept="image/png,image/jpeg,image/gif,image/webp" multiple hidden>
103
- <button type="button" class="mic" id="mic" title="누르고 말하기 (다시 누르면 전송)">마이크</button>
103
+ <button type="button" class="mic" id="mic" data-tip-keep title="누르고 말하기 (다시 누르면 전송)">마이크</button>
104
104
  <textarea id="input" rows="1" placeholder="메시지 보내기" title="Enter 전송, Shift+Enter 줄바꿈"></textarea>
105
105
  <button type="submit" class="send" id="send">전송</button>
106
106
  <button type="button" class="stop" id="interrupt" title="진행 중인 작업 중단" hidden>중단</button>
@@ -178,7 +178,7 @@
178
178
  <div class="enroll-actions"><button type="button" class="text-btn" id="apk-renew" hidden>새로 만들기</button></div>
179
179
  </section>
180
180
  <section class="settings-section gc" id="gc" aria-labelledby="gc-title">
181
- <div class="section-label"><span id="gc-title">정리</span><span class="gc-summary" id="gc-summary" role="status" aria-live="polite"></span></div>
181
+ <div class="section-label"><span id="gc-title">정리</span><span class="gc-head"><span class="gc-summary" id="gc-summary" role="status" aria-live="polite"></span><label class="gc-all" id="gc-all-label" hidden><input type="checkbox" id="gc-all">전체 선택</label></span></div>
182
182
  <ul class="gc-list" id="gc-list" aria-label="정리 후보"></ul>
183
183
  <p class="gc-message" id="gc-message" role="status" aria-live="polite"></p>
184
184
  <div class="gc-actions"><button type="button" class="text-btn" id="gc-clean" disabled>선택한 항목 정리</button></div>
package/web/style.css CHANGED
@@ -412,6 +412,10 @@ button { font: inherit; color: inherit; cursor: pointer; }
412
412
  /* 정리 (improvement 66): inside .settings-body after 앱 내려받기; contain keeps its nowrap rows from widening the card on phones. */
413
413
  .settings-section.gc { contain: inline-size; }
414
414
  .gc-summary { text-transform: none; color: var(--text); }
415
+ .gc-head { display: flex; align-items: center; gap: 12px; min-width: 0; }
416
+ .gc-all { display: inline-flex; align-items: center; gap: 6px; text-transform: none; color: var(--text); font-weight: 400; cursor: pointer; white-space: nowrap; }
417
+ .gc-all[hidden] { display: none; }
418
+ .gc-all input { width: 16px; height: 16px; margin: 0; }
415
419
  .gc-list { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 6px; }
416
420
  .gc-list:empty { display: none; }
417
421
  .gc-row { display: flex; align-items: center; gap: 8px; padding: 8px 10px; background: var(--bg-card); border: 1px solid #4e5058; border-radius: 6px; font-size: 13px; }
@@ -561,3 +565,7 @@ button { font: inherit; color: inherit; cursor: pointer; }
561
565
  .app.status-open .ws-title { font-size: 14px; color: var(--text); }
562
566
  .app.status-open .ws-caption { font-size: 13px; padding: 4px 10px; }
563
567
  }
568
+
569
+ /* Bug 90: the page-drawn tooltip (web/tooltip.js). */
570
+ .zgw-tip { position: fixed; z-index: 10000; max-width: min(360px, calc(100vw - 16px)); padding: 6px 9px; border-radius: 6px; background: #111214; color: var(--text); border: 1px solid #3f4147; box-shadow: 0 4px 14px rgba(0, 0, 0, 0.45); font-size: 12px; line-height: 1.45; white-space: pre-line; overflow-wrap: anywhere; pointer-events: none; }
571
+ .zgw-tip[hidden] { display: none; }
@@ -0,0 +1,16 @@
1
+ export type Box = { left: number; top: number; right: number; bottom: number };
2
+ export type TipElement = {
3
+ getAttribute(name: string): string | null;
4
+ setAttribute(name: string, value: string): void;
5
+ removeAttribute(name: string): void;
6
+ hasAttribute(name: string): boolean;
7
+ };
8
+ export const TIP_GAP: number;
9
+ export const TIP_MARGIN: number;
10
+ export const TIP_ID: string;
11
+ export function placeTip(anchor: Box, tip: { width: number; height: number }, view: { width: number; height: number }): { left: number; top: number; side: "above" | "below" };
12
+ export function adoptTitle(el: Pick<TipElement, "getAttribute" | "setAttribute" | "removeAttribute" | "hasAttribute">): void;
13
+ export function tipText(el: TipElement): string;
14
+ export function addId(list: string | null, id: string): string;
15
+ export function removeId(list: string | null, id: string): string;
16
+ export function installTooltips(doc: Document, win: Window & typeof globalThis): void;
package/web/tooltip.js ADDED
@@ -0,0 +1,114 @@
1
+ // Bug 90: the page draws its own tooltip. Some browsers (the user's, likely Aside) never show the native `title`
2
+ // tooltip, so a `title` moves to `data-tip` and one shared element shows it on hover and keyboard focus, linked with
3
+ // aria-describedby. Bug 161: only elements marked `data-tip-keep` (새 세션, the voice buttons, 확인함, 이벤트) keep a
4
+ // visible tip; every other `title` moves to `aria-description`, so neither the page nor the browser shows a tip for it.
5
+
6
+ export const TIP_GAP = 6;
7
+ export const TIP_MARGIN = 8;
8
+ export const TIP_ID = "zgw-tip";
9
+ const HOVER_DELAY_MS = 250;
10
+ const SELECTOR = "[data-tip]";
11
+
12
+ export function placeTip(anchor, tip, view) {
13
+ const left = Math.max(TIP_MARGIN, Math.min((anchor.left + anchor.right) / 2 - tip.width / 2, view.width - tip.width - TIP_MARGIN));
14
+ const below = anchor.bottom + TIP_GAP;
15
+ const above = anchor.top - TIP_GAP - tip.height;
16
+ const fitsBelow = below + tip.height <= view.height - TIP_MARGIN;
17
+ if (!fitsBelow && above >= TIP_MARGIN) return { left, top: above, side: "above" };
18
+ const top = Math.max(TIP_MARGIN, Math.min(below, view.height - tip.height - TIP_MARGIN));
19
+ return { left, top, side: "below" };
20
+ }
21
+
22
+ export function adoptTitle(el) {
23
+ if (!el.hasAttribute("title")) return;
24
+ const text = el.getAttribute("title");
25
+ el.removeAttribute("title");
26
+ const attr = el.hasAttribute("data-tip-keep") ? "data-tip" : "aria-description";
27
+ if (text) el.setAttribute(attr, text);
28
+ else el.removeAttribute(attr);
29
+ }
30
+
31
+ export function tipText(el) {
32
+ return el.hasAttribute("data-tip-keep") ? el.getAttribute("data-tip") || "" : "";
33
+ }
34
+
35
+ export function addId(list, id) {
36
+ const ids = (list ?? "").split(/\s+/).filter(Boolean);
37
+ return ids.includes(id) ? ids.join(" ") : [...ids, id].join(" ");
38
+ }
39
+
40
+ export function removeId(list, id) {
41
+ return (list ?? "").split(/\s+/).filter((part) => part && part !== id).join(" ");
42
+ }
43
+
44
+ export function installTooltips(doc, win) {
45
+ const tip = doc.createElement("div");
46
+ tip.id = TIP_ID;
47
+ tip.className = "zgw-tip";
48
+ tip.setAttribute("role", "tooltip");
49
+ tip.hidden = true;
50
+ doc.body.append(tip);
51
+ let owner = null;
52
+ let timer = 0;
53
+
54
+ const adoptAll = (root) => {
55
+ if (root.nodeType !== 1) return;
56
+ adoptTitle(root);
57
+ for (const el of root.querySelectorAll("[title]")) adoptTitle(el);
58
+ };
59
+ adoptAll(doc.body);
60
+ new win.MutationObserver((records) => {
61
+ for (const record of records) {
62
+ if (record.type === "attributes") adoptTitle(record.target);
63
+ else for (const node of record.addedNodes) adoptAll(node);
64
+ }
65
+ }).observe(doc.body, { subtree: true, childList: true, attributes: true, attributeFilter: ["title"] });
66
+
67
+ const hide = () => {
68
+ win.clearTimeout(timer);
69
+ if (owner) {
70
+ const rest = removeId(owner.getAttribute("aria-describedby"), TIP_ID);
71
+ if (rest) owner.setAttribute("aria-describedby", rest);
72
+ else owner.removeAttribute("aria-describedby");
73
+ }
74
+ owner = null;
75
+ tip.hidden = true;
76
+ };
77
+ const show = (el) => {
78
+ const text = tipText(el);
79
+ if (!text || !el.isConnected) return hide();
80
+ if (owner !== el) hide();
81
+ owner = el;
82
+ tip.textContent = text;
83
+ tip.style.left = "0px";
84
+ tip.style.top = "0px";
85
+ tip.hidden = false;
86
+ el.setAttribute("aria-describedby", addId(el.getAttribute("aria-describedby"), TIP_ID));
87
+ const place = placeTip(el.getBoundingClientRect(), tip.getBoundingClientRect(), { width: win.innerWidth, height: win.innerHeight });
88
+ tip.style.left = `${Math.round(place.left)}px`;
89
+ tip.style.top = `${Math.round(place.top)}px`;
90
+ tip.dataset.side = place.side;
91
+ };
92
+ const target = (event) => event.target instanceof win.Element ? event.target.closest(SELECTOR) : null;
93
+
94
+ doc.addEventListener("pointerover", (event) => {
95
+ if (event.pointerType === "touch") return;
96
+ const el = target(event);
97
+ if (el === owner) return;
98
+ hide();
99
+ if (el) timer = win.setTimeout(() => show(el), HOVER_DELAY_MS);
100
+ });
101
+ doc.addEventListener("pointerout", (event) => {
102
+ const el = target(event);
103
+ if (el && !(event.relatedTarget instanceof win.Node && el.contains(event.relatedTarget))) hide();
104
+ });
105
+ doc.addEventListener("focusin", (event) => {
106
+ const el = target(event);
107
+ if (el && event.target.matches(":focus-visible")) show(el);
108
+ });
109
+ doc.addEventListener("focusout", hide);
110
+ doc.addEventListener("pointerdown", hide, true);
111
+ doc.addEventListener("keydown", (event) => { if (event.key === "Escape" && owner) hide(); });
112
+ win.addEventListener("scroll", hide, true);
113
+ win.addEventListener("resize", hide);
114
+ }
package/web/update.d.ts CHANGED
@@ -42,4 +42,4 @@ export function questionGate(counters: { readonly pending: number; readonly need
42
42
  export function canUpdate(status: unknown): boolean;
43
43
  export function updateStep(status: number, body: unknown): UpdateStep;
44
44
  export function resultLine(result: unknown): string | null;
45
- export function progressLine(data: unknown): string | null;
45
+ export function progressLine(data: unknown, loadedVersion?: string | null): string | null;
package/web/update.js CHANGED
@@ -89,10 +89,17 @@ export function resultLine(result) {
89
89
  return `${typeof result.message === "string" ? result.message : "업데이트에 실패했습니다."}${typeof result.logPath === "string" && result.logPath !== "" ? ` (로그: ${result.logPath})` : ""}`;
90
90
  }
91
91
 
92
- /** The line for an `update.progress` payload or a GET /api/update/state body; null shows nothing. */
93
- export function progressLine(data) {
92
+ /**
93
+ * The line for an `update.progress` payload or a GET /api/update/state body; null shows nothing. The gateway keeps the last
94
+ * result in state.json for good, so `loadedVersion` (the version the page first loaded on) hides an ok result that ended
95
+ * at that version: the page was reloaded after the update and the "reload the page" line is stale.
96
+ */
97
+ export function progressLine(data, loadedVersion = null) {
94
98
  if (data === null || typeof data !== "object") return null;
95
- if (data.result !== undefined && data.result !== null) return resultLine(data.result);
99
+ if (data.result !== undefined && data.result !== null) {
100
+ if (data.result.ok === true && loadedVersion !== null && data.result.to === loadedVersion) return null;
101
+ return resultLine(data.result);
102
+ }
96
103
  if (typeof data.label !== "string" || data.phase === "done") return null;
97
104
  return `업데이트 중 (${data.from} → ${data.to}): ${data.label}`;
98
105
  }