From e47845edf18bea672ce7827c6c8a6c360c99eff0 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Mon, 27 Jul 2026 16:34:26 +0900 Subject: [PATCH 01/13] docs(devlog): plan login-URL copy parity across all three OAuth surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex/ChatGPT 계정 추가·재인증 모달만 로그인 URL 어포던스가 구식이다. 5919779d가 Workspace 패널과 프로바이더 추가 모달을 고쳤고 이 표면은 빠졌다. URL 전문도, didntOpen 링크도 없고, 복사 실패는 로그인 에러 채널로 흘러간다. wp0은 docs-only 사이클이다. 000 인벤토리로 세 표면의 실제 격차를 파일·라인으로 고정하고, 010/020/030에 공용 컴포넌트 신설 → 표면 C 채택 → 표면 A·B 이관을 diff-level로 미리 적었다. 독립 감사에서 blocker 2건이 나왔고 둘 다 접었다. 첫째, 블록이 복사 상태를 소유하면 url이 바뀔 때 상태를 무효화해야 한다 — 표면 A는 Providers.tsx의 key={item.name} 덕에 우연히 안전하지만 표면 B는 리마운트 key가 없어 'Claude 복사 → Back → Gemini 로그인'에서 거짓 성공 라벨이 남는다. 둘째, 로케일 키 정합 게이트는 lint:i18n이 아니다 — eslint가 src/i18n/**를 globalIgnores로 빼두어 아무 의견이 없고, 실제 게이트는 claude-desktop-locale.test.ts의 키 집합 비교와 TKey 기반 tsc 오류다. 기기 코드 복사 규약 통일은 인과 사슬 밖이라 범위에서 뺐다. --- .../000_inventory.md | 87 +++++++++++ .../010_shared_block.md | 136 ++++++++++++++++++ .../020_codex_modal.md | 106 ++++++++++++++ .../030_migrate_and_verify.md | 84 +++++++++++ 4 files changed, 413 insertions(+) create mode 100644 devlog/_plan/260727_login_url_copy_parity/000_inventory.md create mode 100644 devlog/_plan/260727_login_url_copy_parity/010_shared_block.md create mode 100644 devlog/_plan/260727_login_url_copy_parity/020_codex_modal.md create mode 100644 devlog/_plan/260727_login_url_copy_parity/030_migrate_and_verify.md diff --git a/devlog/_plan/260727_login_url_copy_parity/000_inventory.md b/devlog/_plan/260727_login_url_copy_parity/000_inventory.md new file mode 100644 index 0000000000..e5587b4b39 --- /dev/null +++ b/devlog/_plan/260727_login_url_copy_parity/000_inventory.md @@ -0,0 +1,87 @@ +# 000 — 로그인 URL 복사 어포던스 인벤토리 (조사) + +세 개의 OAuth 로그인 대기 표면이 서로 다른 수준의 어포던스를 들고 있다. +5919779d가 둘을 고쳤고 하나는 그대로 남았다. 아래는 조사 시점(dev @ f327db1e)의 +실측이다. + +## 표면 A — Provider Workspace 설정 패널 + +`gui/src/components/provider-workspace/ProviderAuthPanel.tsx:150-172` + +- URL 전문: `{hintForThis.url}` — 있음 +- 복사 버튼: 3-상태(`idle` / `copied` / `unavailable`), `copyTextToClipboard` 사용 +- 타이머: `linkCopyTimer` ref + 언마운트 `clearTimeout` +- 외부 링크: `prov.didntOpen` 있음 +- a11y: 라벨 span에 `aria-live="polite"` +- 기기 코드 복사(`prov.copyCode`)는 `navigator.clipboard.writeText` 직접 호출 — + 같은 파일 안에서 규약이 갈린다(복사 실패 시 라벨 변화 없음). + +## 표면 B — 프로바이더 추가 모달 + +`gui/src/components/add-provider-oauth-pane.tsx:38-93` + +표면 A와 동일한 블록을 **복제**해서 들고 있다. 상태 이름(`linkCopyState`), +타이머 ref, 렌더 트리, 클래스명까지 같다. 즉 이미 2벌 중복이다. + +## 표면 C — Codex/ChatGPT 계정 추가·재인증 모달 (구멍) + +`gui/src/components/add-codex-account-waiting-step.tsx:41-43` +`gui/src/components/use-add-codex-account-oauth.ts:253-273` + +- URL 전문: **없음**. `ui.authUrl`은 상태에 있지만 화면에 렌더되지 않는다. + 복사 버튼이 듣지 않는 환경(비보안 컨텍스트)에서 URL을 얻을 방법이 0이다. +- 복사 피드백: 2-상태(`copied` boolean)뿐. 실패는 + `dispatch({type:"set-error"})`로 **에러 notice**에 섞인다 + (`codexAuth.loginLinkCopyFailed`). 복사 실패와 로그인 실패가 같은 자리에 + 같은 톤으로 뜬다. +- 복사 구현: `navigator.clipboard?.writeText` → 실패 시 `document.execCommand("copy")` + 수동 textarea 폴백. 저장소 공용 래퍼(`oauth-health-display.ts:126` + `copyTextToClipboard`)를 쓰지 않는 유일한 경로다. +- 타이머: `setTimeout` 2.5초, ref 없음. 연속 클릭 시 앞 타이머가 뒤 라벨을 + 지운다 — 5919779d 감사에서 이미 잡혔던 회귀가 이 파일에만 남았다. +- 외부 링크(`prov.didntOpen`): **없음**. + +`AddCodexAccountModal`은 계정 추가와 재인증 두 진입점을 모두 이 대기 화면으로 +보낸다(`AddCodexAccountModal.tsx:66-84`, `CodexAccountPool.tsx` `openReauth`). +즉 ChatGPT 계정을 추가하는 모든 사용자가 구식 표면을 만난다. + +## 데이터 경로 + +| 표면 | URL 출처 | 상태 보관 | +|------|----------|-----------| +| A | `POST /api/oauth/login` → `loginInfo.url` (`use-providers-oauth.ts:84`) | `Providers.tsx` useState | +| B | 같은 엔드포인트 → `oauthUrl` + `oauthUrlProvider` | `add-provider-modal-reducer.ts` | +| C | `POST /api/codex-auth/login` → `data.url` (`use-add-codex-account-oauth.ts:157`) | `add-codex-account-reducer.ts` `authUrl` | + +서버는 세 경로 모두 URL을 이미 내려주고, 브라우저 열기까지 서버가 이미 시도한다 +(`src/server/management/oauth-account-routes.ts:100-106`, +`src/codex/auth-api.ts:759-761`). 서버 변경은 필요 없다. 이 사실이 블록의 +성격을 결정한다 — 이 블록은 기본 경로가 아니라 **자동 열기 실패 뒤의 복구 경로**다. + +## i18n 현황 + +`prov.copyLink` / `prov.linkCopied` / `prov.linkCopyUnavailable` / +`prov.didntOpen`은 6개 로케일(en/ko/ja/zh/de/ru)에 모두 있다. +`codexAuth.copyLoginLink` / `codexAuth.loginLinkCopied` / +`codexAuth.loginLinkCopyFailed`도 6개 로케일에 있으나 표면 C 전용이다. + +## CSS 현황 + +`gui/src/styles/provider-workspace-settings.css:36-39`에 +`.pwi-auth-open-link` / `.pwi-auth-url-wrap` / `.pwi-auth-url` / +`.pwi-auth-url-actions`가 이미 정의돼 있고 표면 A·B가 공유한다. +접두사 `pwi-`(provider workspace item)가 모달에서도 쓰이는 상태라 +이름과 소속이 어긋난다. + +## 문제 정의 + +1. 표면 C에 URL 전문·외부 링크가 없어 "브라우저가 안 열림" 상황에서 막힌다. +2. 표면 C의 복사 실패가 에러로 오분류되고 3-상태 피드백이 없다. +3. 표면 C의 타이머가 ref로 보호되지 않는다. +4. 표면 A·B가 같은 블록을 2벌 복제해 다음 회귀가 다시 갈라질 준비를 하고 있다. + +## 결론 — 작업 분해 + +- wp1: 공용 컴포넌트 신설(3벌 중복의 단일 소유자) + 단위 테스트. +- wp2: 표면 C 채택 + 복사 규약 통일 + 타이머 하드닝 + 회귀 테스트. +- wp3: 표면 A·B 이관, i18n 정합, 전체 검증. diff --git a/devlog/_plan/260727_login_url_copy_parity/010_shared_block.md b/devlog/_plan/260727_login_url_copy_parity/010_shared_block.md new file mode 100644 index 0000000000..c5c099800d --- /dev/null +++ b/devlog/_plan/260727_login_url_copy_parity/010_shared_block.md @@ -0,0 +1,136 @@ +# 010 — 공용 LoginUrlBlock 컴포넌트 (wp1) + +## 목표 + +로그인 URL 노출 + 복사 + 외부 열기를 한 파일이 소유한다. 현재 표면 A·B가 +같은 JSX를 2벌 들고 있고 표면 C는 아예 없다. 소유자가 하나여야 다음 회귀가 +세 갈래로 갈리지 않는다. + +## Design Read (mini DESIGN.md) + +```yaml +name: login-url-block +role: recovery affordance inside an OAuth waiting state +``` + +읽기: 로컬 프록시 관리 도구(D5~D6 밀도)의 **에러 복구 보조 표면**이다. +사용자는 이미 "브라우저가 안 열렸다"는 실패 상태에 있고, 목표는 URL을 다른 +기기/브라우저로 옮기는 것 하나뿐이다. + +- DESIGN_VARIANCE: 2 +- MOTION_INTENSITY: 1 +- 밀도 프로필: D5 (개발자·운영 도구) +- 근거: 실패 복구 경로에 시각적 변주를 넣는 것은 domain-wrong이다. 이 블록의 + 성공 기준은 "URL을 얻어간다" 하나이며, 기존 대기 패널의 시각 언어를 벗어나면 + 오히려 사용자가 새 UI를 읽는 비용을 문다. + +Do: 기존 `.pwi-auth-url*` 시각 언어를 그대로 승계한다. 복사 결과를 말로 알린다. +Don't: 새 색/모션/아이콘 체계 도입 금지. 토스트 도입 금지(대기 패널 안에서 끝낸다). + +### Lazy-User Gate (UX-LAZY-01) + +이 블록의 결정 지점은 "복사" 하나다. 나머지는 결정이 아니라 정보다. + +- do nothing: 서버가 이미 브라우저를 열어봤다(`openUrl`). 실패했을 때만 이 블록이 의미를 갖는다 → 렌더 조건은 `url`이 있을 때로 유지. +- delete: "URL 전문 표시"를 지울 수 있나? 못 지운다. 클립보드가 막힌 컨텍스트에서 유일한 탈출구다(`user-select: all`). +- absorb: 복사 실패를 시스템이 흡수한다 → 사용자에게 "실패했으니 알아서 하라"가 아니라 URL 전문을 이미 눈앞에 두어 수동 선택이 가능한 상태로 만든다. +- demote: 없음. 이 블록 자체가 이미 실패 상태에서만 의미를 갖는 종속 정보다. + +### UX-STATE-01 — 에러/복구 상태 계약 + +복사 결과는 3-상태다. 실패를 조용히 삼키지 않고, 로그인 에러와 섞지도 않는다. + +| 상태 | 라벨 키 | 의미 | +|------|---------|------| +| idle | `prov.copyLink` | 아직 누르지 않음 | +| copied | `prov.linkCopied` | 클립보드에 들어감 | +| unavailable | `prov.linkCopyUnavailable` | 클립보드 API 부재/거부 — URL 전문을 수동 선택하라는 신호 | + +`unavailable`은 dead-end가 아니다. 바로 위에 URL 전문이 `user-select: all`로 +있으므로 복구 경로가 화면에 남아 있다. + +## 신규 파일 — `gui/src/components/login-url-block.tsx` + +```tsx +import { useEffect, useRef, useState } from "react"; +import { IconExternal, IconLink } from "../icons"; +import { useT } from "../i18n/shared"; +import { copyTextToClipboard } from "../oauth-health-display"; + +export type LoginUrlCopyState = "idle" | "copied" | "unavailable"; + +export function LoginUrlBlock({ url, className }: { url: string; className?: string }) { ... } +``` + +- props는 `url` 하나(+ 선택적 `className`). 상태·타이머·i18n을 컴포넌트가 소유한다. + 호출부가 복사 상태를 들고 있을 이유가 없다 — 표면 A·B의 `linkCopyState`, + 표면 C의 `ui.copied`가 전부 사라진다. +- `url`이 빈 문자열이면 `null`을 반환한다. 호출부 조건문을 단순화한다. +- 타이머는 `useRef | null>`. 클릭할 때마다 + `clearTimeout` 후 재설정, 언마운트 시 정리. 이유: 같은 outcome을 연속으로 + 누르면 functional-update 가드로는 앞 타이머가 뒤 라벨을 지운다(5919779d 감사 결론). +- **`url`이 바뀌면 복사 상태를 `idle`로 되돌리고 대기 중인 타이머를 취소한다** + (`useEffect(..., [url])`). A 감사 blocker #1: 표면 A는 + `Providers.tsx:210`의 `key={item.name}`로 리마운트되어 우연히 안전하지만, + 표면 B(`AddProviderModal.tsx:253`)에는 그런 key가 없다. 지금은 복사 상태가 + pane 지역 상태라 무해하지만, 블록이 상태를 소유하는 순간 + "Claude 복사 → Back → Gemini 로그인" 경로에서 **Gemini URL 위에 '복사됨'이 + 남는다** — 클립보드에는 Claude URL이 든 채로. 거짓 성공이다. + 호출부 `key={url}`이 아니라 컴포넌트 내부 effect로 해결한다. 상태를 소유한 + 쪽이 그 상태의 무효화도 소유해야 한다(이 문서의 단일 소유자 논지 그대로). +- 라벨 span에 `aria-live="polite"`. 상태 전환을 스크린리더에 고지한다. +- 외부 링크 `prov.didntOpen`은 블록 내부에 포함한다. 표면 A·B가 지금 이 링크를 + 블록 밖에 두고 있으나, "URL을 얻는다"라는 하나의 사용자 의도에 속하므로 + 블록이 함께 소유하는 것이 옳다. + +렌더 구조(기존 마크업/클래스 승계): + +```tsx +
+ {url} +
+ + + +
+
+``` + +클래스명은 이번 단계에서 바꾸지 않는다. `pwi-` 접두사가 모달에서도 쓰이는 +어긋남은 사실이나, 리네임은 CSS·테스트·세 표면을 동시에 건드리므로 이 루프의 +목표(기능 동등성)와 섞으면 회귀 원인을 분리할 수 없다. 별도 항목으로 남긴다. + +## 신규 테스트 — `gui/tests/login-url-block.test.tsx` + +`gui/tests/provider-auth-login-copy-link.test.tsx`의 happy-dom + act 하네스를 재사용. + +1. `url`이 주어지면 URL 전문이 DOM에 렌더된다. +2. 복사 클릭 시 `navigator.clipboard.writeText`가 그 URL로 호출되고 라벨이 + `prov.linkCopied`로 바뀐다. +3. 클립보드 API 부재 시 라벨이 `prov.linkCopyUnavailable`로 바뀐다(조용한 실패 금지). +4. 2.5초 창 안에서 같은 outcome으로 재클릭해도 뒤 클릭 피드백이 제 수명을 다한다 + (타이머 ref 회귀 가드). +5. `prov.didntOpen` 외부 링크가 `href={url}`로 존재한다. +6. `url=""`이면 아무것도 렌더하지 않는다. +7. **url A로 복사한 뒤 url B로 리렌더하면 라벨이 `prov.copyLink`로 돌아온다** + (blocker #1 가드). 이 테스트는 effect를 지우면 실패해야 한다. + +버튼은 라벨 텍스트가 아니라 `.pwi-auth-url-actions button` 구조로 조회한다. + +## 범위 밖 (wp1) + +- 표면 A·B·C의 호출부 변경 — wp2/wp3. +- CSS 클래스 리네임. +- 서버/`src/` 변경. + +## 완료 기준 + +- `cd gui && bun x tsc -b` exit 0 — 단 이는 `src`만 덮는다. + `gui/tsconfig.app.json`의 `include`는 `src`뿐이므로 **신규 테스트 파일의 타입은 + 이 명령이 증명하지 않는다**(A 감사 #8). 테스트 타입 오류는 `bun test`에서만 드러난다. +- `cd gui && bun test tests/login-url-block.test.tsx` 전건 통과 +- 컴포넌트를 되돌리면 신규 테스트가 실패한다(가드 실효 확인) diff --git a/devlog/_plan/260727_login_url_copy_parity/020_codex_modal.md b/devlog/_plan/260727_login_url_copy_parity/020_codex_modal.md new file mode 100644 index 0000000000..3025122d29 --- /dev/null +++ b/devlog/_plan/260727_login_url_copy_parity/020_codex_modal.md @@ -0,0 +1,106 @@ +# 020 — Codex 계정 추가/재인증 모달 채택 (wp2) + +이 루프의 본체다. 사용자가 보고한 증상("계정 추가할 때 복사 버튼 관련")이 +바로 이 표면이다. + +## 변경 1 — `gui/src/components/add-codex-account-waiting-step.tsx` + +현재: + +```tsx + +``` + +변경 후: 위 버튼을 `` 하나로 교체한다. + +**빈 URL 구간의 거동 변화(A 감사 #5).** 지금은 `disabled={!authUrl}` 버튼이 +회색으로 자리를 지킨다. 블록은 `url=""`이면 `null`을 반환하므로 그 자리가 빈다. +재인증 진입은 `initialAddCodexAccountUiState`가 곧바로 `step: "oauth-waiting"`을 +주므로(`AddCodexAccountModal.tsx:20`) `startOAuth` 응답 전 첫 렌더가 실제로 그 상태다. +받아들인다 — 대기 화면에는 이미 스피너와 `codexAuth.oauthWaiting` 문구가 있어 +"준비 중"이라는 신호가 중복으로 존재하고, 눌리지 않는 버튼보다 아무것도 없는 쪽이 +거짓 어포던스를 만들지 않는다. `cancelLogin`이 `authUrl`을 비우면 블록이 사라지는 +것도 같은 이유로 옳다. + +- `copied` prop 제거. `onCopyLoginLink` prop 제거. +- import에서 `IconLink` 제거(더 이상 이 파일이 아이콘을 직접 쓰지 않는다). +- 배치: `

` 바로 아래, 수동 코드 입력 블록 위. + 대기 화면의 읽기 순서는 "기다리는 중이다 → URL은 여기 있다 → 안 되면 코드를 + 붙여넣어라"가 되어야 한다. 지금은 복사 버튼이 full-width로 떠 있어 위계가 없다. +- 모달 폭이 440px(`AddCodexAccountModal.tsx`)이고 OAuth URL은 길다. + `.pwi-auth-url`이 `overflow-wrap: anywhere`를 이미 갖고 있어 그대로 통한다. + +## 변경 2 — `gui/src/components/use-add-codex-account-oauth.ts` + +`copyLoginLink`(253-273, 닫는 `};` 포함) 전체를 삭제한다. + +- `document.execCommand("copy")` 수동 textarea 폴백 삭제. 저장소 공용 래퍼 + `copyTextToClipboard`가 비보안 컨텍스트에서 `false`를 돌려주는 계약이고, + 그 실패를 3-상태 라벨로 보여주는 것이 이제 컴포넌트 책임이다. +- 복사 실패 시 `set-error` 디스패치 제거. 로그인 에러 채널을 복사 실패가 + 오염하던 문제가 여기서 사라진다. +- 반환 객체에서 `copyLoginLink` 제거(`:311`). +- 훅의 `ui` 파라미터 타입(`:20-26`)에는 `copied`가 없다. 시그니처 변경은 없다. + +## 변경 3 — `gui/src/components/add-codex-account-reducer.ts` + +- `AddCodexAccountUiState`에서 `copied: boolean` 필드 제거. +- `initialAddCodexAccountUiState`에서 `copied: false` 제거. +- 액션 유니온에서 `{ type: "set-copied"; copied: boolean }` 제거. +- reducer의 `case "set-copied"` 제거. + +이 상태는 이제 컴포넌트 지역 상태다. 모달 reducer가 복사 피드백 같은 +순간적 UI 상태를 들고 있을 이유가 없다. + +## 변경 4 — `gui/src/components/AddCodexAccountModal.tsx` + +- `oauth` 구조분해에서 `copyLoginLink` 제거. +- ``에서 `copied={ui.copied}` / + `onCopyLoginLink={...}` prop 제거. + +## i18n + +새 키는 필요 없다. 표면 C가 `prov.copyLink` / `prov.linkCopied` / +`prov.linkCopyUnavailable` / `prov.didntOpen`을 쓰게 된다. + +`codexAuth.copyLoginLink` / `codexAuth.loginLinkCopied` / +`codexAuth.loginLinkCopyFailed` 3개 키는 소비처가 0이 된다. 6개 로케일에서 +삭제한다 — 죽은 키를 남기면 다음 사람이 이 표면에 별도 규약이 있다고 오해한다. +(5919779d 조사에서 "i18n 키는 살아 있고 소비처만 0건"이 오히려 혼란의 근거였다.) + +**반드시 6개 파일을 같은 커밋에서 함께 지운다(A 감사 blocker #2).** +삭제 위치: `en.ts:1006-1008`, `ko.ts:707-709`, `zh.ts:707-709`, +`ja.ts:960-962`, `de.ts:690-692`, `ru.ts:1005-1007`. +정합 게이트는 `lint:i18n`이 **아니다** — `gui/eslint.config.js:12`가 +`src/i18n/**`를 globalIgnores에 넣어 로케일 키에 대해 아무 의견이 없다. +실제 게이트는 두 개다: `gui/tests/claude-desktop-locale.test.ts`의 +"locale key sets stay identical to the English source"(텍스트 파싱 집합 비교)와, +`en.ts:1361`의 `TKey = keyof typeof en` 때문에 발생하는 `tsc` 오류. + +## 신규 테스트 — `gui/tests/add-codex-account-login-url.test.tsx` + +기존 `gui/tests/add-codex-account-oauth.test.tsx`의 fetch 스텁 하네스를 따른다. +`/api/codex-auth/login`이 `{ url, flowId }`를 반환하도록 하고 모달을 마운트한다. + +1. 대기 단계에서 인증 URL 전문이 DOM에 렌더된다(현재는 렌더되지 않음 → 실패해야 함). +2. `prov.didntOpen` 외부 링크가 그 URL을 가리킨다. +3. 복사 클릭 시 클립보드에 URL이 들어가고 라벨이 `prov.linkCopied`로 바뀐다. +4. 클립보드 부재 시 라벨이 `prov.linkCopyUnavailable`이 되고, + **에러 notice(`.notice-err`)는 뜨지 않는다** — 복사 실패의 에러 채널 오염 금지. +5. 재인증 진입(`reauthAccountId` 지정)에서도 같은 블록이 렌더된다. + **`startOAuth` 응답을 명시적으로 `act`로 기다린 뒤 단언한다** — 첫 렌더는 + `authUrl: ""`이라 기다리지 않으면 flaky다(A 감사 #5). + +## 완료 기준 + +- `cd gui && bun x tsc -b` exit 0 +- `cd gui && bun test tests` 전건 통과(기존 `add-codex-account-oauth.test.tsx`, + `claude-desktop-locale.test.ts` 포함) +- `bun test tests/codex-auth-modal-status.test.ts` (루트) 통과 — 이 테스트가 + `add-codex-account-waiting-step.tsx`의 `aria-live="polite"`와 수동 코드 + disabled 조건을 소스 텍스트로 고정하고 있으므로 대기 화면 편집 시 함께 본다. +- 변경을 되돌리면 신규 테스트가 실패한다 +- `rg "copyLoginLink|set-copied|loginLinkCopyFailed|loginLinkCopied" gui/src` → 0건 + (`loginLinkCopied`를 빼면 로케일 잔존을 놓친다 — A 감사 #9) diff --git a/devlog/_plan/260727_login_url_copy_parity/030_migrate_and_verify.md b/devlog/_plan/260727_login_url_copy_parity/030_migrate_and_verify.md new file mode 100644 index 0000000000..e6608541d7 --- /dev/null +++ b/devlog/_plan/260727_login_url_copy_parity/030_migrate_and_verify.md @@ -0,0 +1,84 @@ +# 030 — 표면 A·B 이관 + i18n 정합 + 전체 검증 (wp3) + +## 변경 1 — `gui/src/components/provider-workspace/ProviderAuthPanel.tsx` + +- `linkCopyState` / `linkCopyTimer` / `copyLoginUrl` / 관련 `useEffect` 정리 훅 삭제. +- `hintForThis.url &&` 블록을 ``로 교체. + 기존 `prov.didntOpen` 링크는 블록이 소유하므로 별도 렌더를 제거한다. +- import 정리(A 감사 #4로 실측 확정): `IconLink`, `IconExternal`, `useEffect`, + `useRef`가 미사용이 되어 제거 대상이다. **`useState`는 남긴다** — + `addingKey`/`newKey`/`keyBusy`/`deviceCodeCopied`(`:49-52`)와 + `copiedDoctorFor`(`:68`)가 계속 쓴다. + `gui/tsconfig.app.json`이 `noUnusedLocals: true`라 틀리면 `tsc`가 즉시 잡는다. +- `copyTextToClipboard`는 doctor 복사에서 계속 쓰므로 import 유지. + +### 보류 — 기기 코드 복사 (A 감사 #10으로 범위에서 제외) + +같은 파일 147-152의 `prov.copyCode` 버튼이 `navigator.clipboard.writeText`를 +직접 부르고 실패를 `.catch(() => {})`로 삼킨다. 실제 결함이다. 그러나 이 루프의 +인과 사슬(로그인 URL 어포던스) 밖이고, 테스트가 없는 경로이며, 이 계획이 스스로 +내건 "되돌리면 테스트가 실패한다" 보증을 흐린다. **별도 항목으로 남긴다.** + +## 변경 2 — `gui/src/components/add-provider-oauth-pane.tsx` + +- `linkCopyState` / `linkCopyTimer` / `copyAuthUrl` / 정리 `useEffect` 삭제. +- `oauthBusy && oauthUrl` 블록을 ``로 교체 + (렌더 조건 `oauthBusy &&`는 유지 — 스테일 URL 노출 방지 계약). +- import에서 `IconExternal` / `IconLink` / `copyTextToClipboard` / + `useEffect` / `useRef` / `useState` 중 미사용분 제거. +- **`oauthUrlProvider` 필터와 reducer의 `set-oauth-url` 가드는 건드리지 않는다.** + A/B 로그인 경쟁 회귀(`add-provider-oauth-url-leak.test.tsx`)를 지키는 계약이다. + +## 변경 3 — i18n 6개 로케일 + +- `codexAuth.copyLoginLink` / `codexAuth.loginLinkCopied` / + `codexAuth.loginLinkCopyFailed` 삭제(en/ko/ja/zh/de/ru). +- 나머지 키 변경 없음. +- 정합 게이트는 `lint:i18n`이 아니라 + `cd gui && bun test tests/claude-desktop-locale.test.ts`다 + (`gui/eslint.config.js:12`가 `src/i18n/**`를 무시한다 — A 감사 blocker #2). + `lint:i18n`은 UI 파일의 하드코딩 문자열 규칙용이므로 별도로 계속 돌린다. + +## 변경 4 — 기존 테스트 무수정 통과 확인 + +`gui/tests/provider-auth-login-copy-link.test.tsx`와 +`gui/tests/add-provider-oauth-url-leak.test.tsx`는 라벨 텍스트와 구조 +(`.pwi-auth-url-actions button`)로 조회하므로 이관 후에도 그대로 통과해야 한다. +**테스트를 고쳐서 통과시키지 않는다.** 통과하지 않으면 이관이 동등하지 않다는 뜻이다. + +## 검증 (wp3 종료 조건) + +| 명령 | 기대 | +|------|------| +| `bun run typecheck` | exit 0 | +| `cd gui && bun x tsc -b` | exit 0 | +| `cd gui && bun test tests` | 전건 통과 | +| `bun run lint:gui` | exit 0 | +| `cd gui && bun run lint:i18n` | exit 0 (로케일 키 정합은 이 명령이 보지 않는다) | +| `cd gui && bun test tests/claude-desktop-locale.test.ts` | 로케일 키 집합 동일 | +| `bun test tests/codex-auth-modal-status.test.ts` | 대기 화면 소스 계약 유지 | +| `bun run privacy:scan` | exit 0 | +| `bun run test` (루트) | 기준선 대비 신규 실패 0 | + +루트 스위트는 착수 전 기준선을 먼저 측정해 비교한다(과거 이 저장소에서 +`tests/management-provider-validation.test.ts`가 병렬 포트 경합으로 flake를 +낸 전례가 있다 — 기준선 없이 "5건 실패"를 신규로 오판하면 안 된다). + +## 소비처 확인 + +`rg -n "LoginUrlBlock" gui/src` → 표면 A·B·C 3건 + 컴포넌트 정의 1건. +`rg -n "pwi-auth-url-wrap" gui/src --glob '*.tsx'` → 컴포넌트 1건만. + +## docs-site + +`rg`로 확인한 결과 `docs-site/`에 copy-login-link 어포던스를 언급하는 문장이 +0건이다. 표면 C가 URL과 외부 링크를 얻는 것은 사용자에게 보이는 변화지만, +문서가 이 화면의 구성 요소를 열거한 적이 없으므로 모순되는 서술이 생기지 않는다. +**문서 변경 없음 — 근거를 남기고 넘어간다**(A 감사 #11). + +## 범위 밖 + +- `pwi-` 클래스 접두사 리네임(별도 항목). +- `prov.copyCode` 기기 코드 복사 규약 통일(별도 항목). +- 계정 풀/자동 전환 로직. +- push, 배포. From e74da67939fa68340d4658b394381081927ade67 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Mon, 27 Jul 2026 16:37:01 +0900 Subject: [PATCH 02/13] feat(gui): add the shared OAuth login-URL block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 세 로그인 표면이 같은 블록을 필요로 하는데 둘은 복제본을 들고 있고 하나는 아무것도 없었다. 소유자를 하나로 만든다. URL 전문, 3-상태 복사, 수동 열기 링크, aria-live 고지가 전부 이 컴포넌트 안에 있다. props는 url 하나다. 호출부가 복사 상태를 들고 있을 이유가 없다. url이 바뀌면 복사 피드백을 무효화한다. Workspace 패널은 Providers.tsx의 key={item.name} 덕에 리마운트되어 우연히 안전하지만, 프로바이더 추가 모달은 그런 key가 없다. 상태를 컴포넌트로 옮기는 순간 'Claude 복사 → Back → Gemini 로그인'에서 Gemini URL 위에 '복사됨'이 남는다 — 클립보드에는 Claude URL이 든 채로. 거짓 성공이라 호출부 key가 아니라 내부 effect로 막는다. 타이머는 ref로 들고 재클릭·언마운트·url 변경에서 clearTimeout한다. 복사 실패는 삼키지 않는다. copyTextToClipboard가 비보안 컨텍스트에서 false를 돌려주므로 unavailable 라벨을 띄우고, 바로 위 URL 전문이 user-select: all이라 수동 선택이라는 복구 경로가 화면에 남는다. 검증: gui/tests/login-url-block.test.tsx 6 pass. url 리셋 effect를 지우면 1건, 재클릭 clearTimer를 지우면 1건 실패한다. tsc -b exit 0. --- gui/src/components/login-url-block.tsx | 72 ++++++++++++ gui/tests/login-url-block.test.tsx | 154 +++++++++++++++++++++++++ 2 files changed, 226 insertions(+) create mode 100644 gui/src/components/login-url-block.tsx create mode 100644 gui/tests/login-url-block.test.tsx diff --git a/gui/src/components/login-url-block.tsx b/gui/src/components/login-url-block.tsx new file mode 100644 index 0000000000..446f53ba95 --- /dev/null +++ b/gui/src/components/login-url-block.tsx @@ -0,0 +1,72 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { IconExternal, IconLink } from "../icons"; +import { useT } from "../i18n/shared"; +import { copyTextToClipboard } from "../oauth-health-display"; + +const FEEDBACK_MS = 2500; + +type CopyState = "idle" | "copied" | "unavailable"; + +/** + * Recovery affordance for an OAuth waiting state: the proxy already tried to + * open the browser server-side, so this block only matters once that failed. + * It exposes the authorization URL as selectable text, copies it, and offers a + * manual open — the single owner for all three login surfaces (workspace panel, + * add-provider modal, Codex account modal). + */ +export function LoginUrlBlock({ url }: { url: string }) { + const t = useT(); + const [copyState, setCopyState] = useState("idle"); + const timerRef = useRef | null>(null); + + const clearTimer = useCallback(() => { + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + }, []); + + useEffect(() => clearTimer, [clearTimer]); + + // A new URL invalidates the old feedback. Without this the block would claim + // "copied" over a URL the clipboard never received — the workspace panel is + // remounted by its provider key, but the add-provider modal is not. + useEffect(() => { + clearTimer(); + setCopyState("idle"); + }, [url, clearTimer]); + + if (!url) return null; + + const copy = () => { + void copyTextToClipboard(url).then((ok) => { + clearTimer(); + setCopyState(ok ? "copied" : "unavailable"); + timerRef.current = setTimeout(() => { + timerRef.current = null; + setCopyState("idle"); + }, FEEDBACK_MS); + }); + }; + + const label = copyState === "copied" + ? t("prov.linkCopied") + : copyState === "unavailable" + ? t("prov.linkCopyUnavailable") + : t("prov.copyLink"); + + return ( +

+ {url} +
+ + + +
+
+ ); +} diff --git a/gui/tests/login-url-block.test.tsx b/gui/tests/login-url-block.test.tsx new file mode 100644 index 0000000000..49f55e02b0 --- /dev/null +++ b/gui/tests/login-url-block.test.tsx @@ -0,0 +1,154 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import { LanguageProvider } from "../src/i18n/provider"; +import { LoginUrlBlock } from "../src/components/login-url-block"; + +/** + * Contract for the shared OAuth login-URL block. It owns the copy state for + * all three login surfaces, so it also owns invalidating that state when the + * URL changes — the add-provider modal keeps this block mounted across a + * provider switch and would otherwise show "copied" over a foreign URL. + */ + +const URL_A = "https://auth.example.test/oauth/authorize?client_id=aaa&state=111"; +const URL_B = "https://auth.example.test/oauth/authorize?client_id=bbb&state=222"; + +const globals = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previous: Record<(typeof globals)[number], unknown>; +let win: Window; +let host: HTMLElement; +let root: Root | null = null; +let clipboardWrites: string[] = []; + +function installClipboard(available: boolean) { + clipboardWrites = []; + Object.defineProperty(win.navigator, "clipboard", { + configurable: true, + value: available + ? { writeText: async (text: string) => { clipboardWrites.push(text); } } + : undefined, + }); +} + +beforeEach(() => { + previous = Object.fromEntries(globals.map((k) => [k, Reflect.get(globalThis, k)])) as typeof previous; + win = new Window({ url: "http://localhost/" }); + Object.defineProperty(win.navigator, "language", { configurable: true, value: "en-US" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: win.document }, + window: { configurable: true, value: win }, + navigator: { configurable: true, value: win.navigator }, + localStorage: { configurable: true, value: win.localStorage }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + installClipboard(true); + host = win.document.createElement("div") as unknown as HTMLElement; + win.document.body.appendChild(host as never); +}); + +afterEach(async () => { + if (root) { + const current = root; + await act(async () => { current.unmount(); }); + root = null; + } + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previous[key] }); + } + await win.happyDOM?.close?.(); +}); + +async function render(url: string) { + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root ??= createRoot(host); + root.render( + + + , + ); + }); +} + +function copyButton(): HTMLButtonElement { + const button = host.querySelector(".pwi-auth-url-actions button"); + expect(button).toBeTruthy(); + return button as HTMLButtonElement; +} + +async function clickCopy() { + await act(async () => { + copyButton().dispatchEvent(new win.MouseEvent("click", { bubbles: true })); + await new Promise((r) => setTimeout(r, 0)); + }); +} + +test("renders the URL as selectable text and copies it", async () => { + await render(URL_A); + + expect(host.textContent).toContain(URL_A); + + await clickCopy(); + + expect(clipboardWrites).toEqual([URL_A]); + expect(host.textContent).toContain("Copied"); +}); + +test("reports an unusable clipboard instead of a false success", async () => { + installClipboard(false); + await render(URL_A); + + await clickCopy(); + + expect(clipboardWrites).toEqual([]); + expect(host.textContent).toContain("Clipboard unavailable"); +}); + +test("keeps the latest feedback for its full window across repeated copies", async () => { + await render(URL_A); + + await clickCopy(); + await act(async () => { await new Promise((r) => setTimeout(r, 200)); }); + await clickCopy(); + + expect(clipboardWrites).toEqual([URL_A, URL_A]); + + // Past the first click's expiry but before the second's: an unguarded timer + // would already have wiped the second click's label. + await act(async () => { await new Promise((r) => setTimeout(r, 2400)); }); + expect(host.textContent).toContain("Copied"); +}); + +test("a new URL resets stale copy feedback", async () => { + await render(URL_A); + await clickCopy(); + expect(host.textContent).toContain("Copied"); + + await render(URL_B); + + expect(host.textContent).toContain(URL_B); + expect(host.textContent).not.toContain(URL_A); + expect(host.textContent).not.toContain("Copied"); + expect(host.textContent).toContain("Copy link"); +}); + +test("offers a manual open fallback alongside the copy button", async () => { + await render(URL_A); + + const anchor = Array.from(host.querySelectorAll("a")).find( + (el) => el.getAttribute("href") === URL_A, + ); + expect(anchor).toBeTruthy(); + expect(anchor?.getAttribute("target")).toBe("_blank"); + expect(anchor?.getAttribute("rel")).toBe("noreferrer"); + expect(host.textContent).toContain("Didn't open?"); +}); + +test("renders nothing without a URL", async () => { + await render(""); + + expect(host.querySelector(".pwi-auth-url-wrap")).toBeNull(); + expect(host.textContent).not.toContain("Copy link"); +}); From 3f6f4908e2710b37258da834a22e4af814c87a8e Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Mon, 27 Jul 2026 16:38:10 +0900 Subject: [PATCH 03/13] fix(gui): derive login-URL copy feedback instead of resetting in an effect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit react-hooks/set-state-in-effect가 url 변경 리셋 effect를 막았다. 규칙이 옳다 — effect로 상태를 되돌리면 캐스케이딩 렌더가 된다. 피드백이 자기가 속한 url을 함께 들고 다니게 바꿨다. url이 달라지면 렌더 시점에 불일치로 읽혀 자동으로 idle이 된다. 무효화 계약은 그대로고 effect만 사라졌다. 검증: gui lint exit 0, tsc -b exit 0, 6 pass. url 스코프를 벗기면 1건, 재클릭 clearTimer를 벗기면 1건 실패한다. --- gui/src/components/login-url-block.tsx | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/gui/src/components/login-url-block.tsx b/gui/src/components/login-url-block.tsx index 446f53ba95..1ccf5b67a4 100644 --- a/gui/src/components/login-url-block.tsx +++ b/gui/src/components/login-url-block.tsx @@ -5,7 +5,7 @@ import { copyTextToClipboard } from "../oauth-health-display"; const FEEDBACK_MS = 2500; -type CopyState = "idle" | "copied" | "unavailable"; +type CopyOutcome = "copied" | "unavailable"; /** * Recovery affordance for an OAuth waiting state: the proxy already tried to @@ -16,7 +16,10 @@ type CopyState = "idle" | "copied" | "unavailable"; */ export function LoginUrlBlock({ url }: { url: string }) { const t = useT(); - const [copyState, setCopyState] = useState("idle"); + // Feedback carries the URL it belongs to. A new URL therefore reads as idle + // without an effect: the add-provider modal keeps this block mounted across a + // provider switch, and stale "copied" would claim a URL the clipboard never got. + const [feedback, setFeedback] = useState<{ url: string; outcome: CopyOutcome } | null>(null); const timerRef = useRef | null>(null); const clearTimer = useCallback(() => { @@ -28,30 +31,24 @@ export function LoginUrlBlock({ url }: { url: string }) { useEffect(() => clearTimer, [clearTimer]); - // A new URL invalidates the old feedback. Without this the block would claim - // "copied" over a URL the clipboard never received — the workspace panel is - // remounted by its provider key, but the add-provider modal is not. - useEffect(() => { - clearTimer(); - setCopyState("idle"); - }, [url, clearTimer]); - if (!url) return null; + const outcome = feedback?.url === url ? feedback.outcome : null; + const copy = () => { void copyTextToClipboard(url).then((ok) => { clearTimer(); - setCopyState(ok ? "copied" : "unavailable"); + setFeedback({ url, outcome: ok ? "copied" : "unavailable" }); timerRef.current = setTimeout(() => { timerRef.current = null; - setCopyState("idle"); + setFeedback(null); }, FEEDBACK_MS); }); }; - const label = copyState === "copied" + const label = outcome === "copied" ? t("prov.linkCopied") - : copyState === "unavailable" + : outcome === "unavailable" ? t("prov.linkCopyUnavailable") : t("prov.copyLink"); From 60296d0808c421db75950d3a205537b654fc5f16 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Mon, 27 Jul 2026 16:40:51 +0900 Subject: [PATCH 04/13] fix(gui): give the Codex account modal the login URL, not just a copy button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ChatGPT 계정을 추가하거나 재인증할 때 대기 화면에 복사 버튼 하나만 있었다. 클립보드가 막힌 환경이면 인증 URL을 얻을 방법이 없다. 상태에는 authUrl이 있었는데 화면에 내보내지 않았다. 세 번째이자 마지막 표면이다. 5919779d가 Workspace 패널과 프로바이더 추가 모달을 고칠 때 이 경로는 '이미 정상 동작'으로 분류돼 범위 밖에 있었다. 복사 버튼이 있으니 동작한다고 본 것인데, 복사가 되지 않는 경우가 바로 이 어포던스가 필요한 경우였다. 공용 LoginUrlBlock으로 교체한다. URL 전문이 화면에 나오고, 브라우저에서 직접 열 링크가 생기고, 복사 결과가 3-상태로 뜬다. 복사 실패를 로그인 에러와 분리한다. 기존 copyLoginLink는 실패하면 set-error를 던져 로그인 실패와 같은 자리에 같은 톤으로 붉은 notice를 띄웠다. 이제 버튼 라벨만 '클립보드를 사용할 수 없음'으로 바뀌고, 바로 위 URL 전문이 user-select: all이라 수동 선택이라는 복구 경로가 남는다. execCommand textarea 폴백을 지운다. 저장소 공용 래퍼 copyTextToClipboard가 비보안 컨텍스트에서 false를 돌려주는 계약을 이미 갖고 있고, 이제 그 false를 어떻게 보여줄지는 컴포넌트 책임이다. reducer의 copied 상태도 함께 사라진다 — 순간적 UI 피드백은 모달 reducer가 들 것이 아니다. codexAuth.copyLoginLink / loginLinkCopied / loginLinkCopyFailed 3개 키를 6개 로케일에서 지운다. 소비처가 0이 됐고, 죽은 키를 남기면 다음 사람이 이 표면에 별도 규약이 있다고 오해한다. 검증: gui 313 pass 0 fail, typecheck exit 0, tsc -b exit 0, lint/lint:i18n exit 0, claude-desktop-locale 키 집합 동일, 루트 codex-auth-modal-status + provider-workspace-auth 16 pass. 신규 3건은 LoginUrlBlock을 빼면 전부 실패한다. --- gui/src/components/AddCodexAccountModal.tsx | 4 +- .../components/add-codex-account-reducer.ts | 5 - .../add-codex-account-waiting-step.tsx | 10 +- .../components/use-add-codex-account-oauth.ts | 23 --- gui/src/i18n/de.ts | 3 - gui/src/i18n/en.ts | 3 - gui/src/i18n/ja.ts | 3 - gui/src/i18n/ko.ts | 3 - gui/src/i18n/ru.ts | 3 - gui/src/i18n/zh.ts | 3 - .../add-codex-account-login-url.test.tsx | 145 ++++++++++++++++++ 11 files changed, 148 insertions(+), 57 deletions(-) create mode 100644 gui/tests/add-codex-account-login-url.test.tsx diff --git a/gui/src/components/AddCodexAccountModal.tsx b/gui/src/components/AddCodexAccountModal.tsx index 969ad82e65..0217dd3af0 100644 --- a/gui/src/components/AddCodexAccountModal.tsx +++ b/gui/src/components/AddCodexAccountModal.tsx @@ -22,7 +22,7 @@ export default function AddCodexAccountModal({ const dialogRef = useRef(null); const oauth = useAddCodexAccountOAuth({ apiBase, reauthAccountId, ui, dispatch, t }); - const { manualCodeBusy, manualCodeWaiting, bindCallbacks, closeModal, startOAuth, copyLoginLink, submitManualCode } = oauth; + const { manualCodeBusy, manualCodeWaiting, bindCallbacks, closeModal, startOAuth, submitManualCode } = oauth; useEffect(() => { bindCallbacks(onAdded, onClose); @@ -69,7 +69,6 @@ export default function AddCodexAccountModal({ { void copyLoginLink(); }} onManualCodeChange={value => dispatch({ type: "set-manual-code", manualCode: value })} onSubmitManualCode={() => { void submitManualCode(); }} onClose={closeModal} diff --git a/gui/src/components/add-codex-account-reducer.ts b/gui/src/components/add-codex-account-reducer.ts index 242dc00105..6e4c1718a0 100644 --- a/gui/src/components/add-codex-account-reducer.ts +++ b/gui/src/components/add-codex-account-reducer.ts @@ -7,7 +7,6 @@ export interface AddCodexAccountUiState { id: string; error: string; authUrl: string; - copied: boolean; manualCode: string; manualCodeState: ManualCodeState; statusNotice: string; @@ -20,7 +19,6 @@ export const initialAddCodexAccountUiState = (reauthAccountId?: string): AddCode id: "", error: "", authUrl: "", - copied: false, manualCode: "", manualCodeState: "idle", statusNotice: "", @@ -33,7 +31,6 @@ export type AddCodexAccountUiAction = | { type: "set-id"; id: string } | { type: "set-error"; error: string } | { type: "set-auth-url"; authUrl: string } - | { type: "set-copied"; copied: boolean } | { type: "set-manual-code"; manualCode: string } | { type: "set-manual-code-state"; manualCodeState: ManualCodeState } | { type: "set-status-notice"; statusNotice: string; statusTone?: StatusTone } @@ -52,8 +49,6 @@ export function addCodexAccountUiReducer(state: AddCodexAccountUiState, action: return { ...state, error: action.error }; case "set-auth-url": return { ...state, authUrl: action.authUrl }; - case "set-copied": - return { ...state, copied: action.copied }; case "set-manual-code": return { ...state, manualCode: action.manualCode }; case "set-manual-code-state": diff --git a/gui/src/components/add-codex-account-waiting-step.tsx b/gui/src/components/add-codex-account-waiting-step.tsx index d8a55d47fa..6ed9c29718 100644 --- a/gui/src/components/add-codex-account-waiting-step.tsx +++ b/gui/src/components/add-codex-account-waiting-step.tsx @@ -1,11 +1,10 @@ -import { IconLink } from "../icons"; import { useT } from "../i18n/shared"; +import { LoginUrlBlock } from "./login-url-block"; import type { StatusTone } from "./add-codex-account-reducer"; export function AddCodexAccountWaitingStep({ reauthAccountId, authUrl, - copied, manualCode, manualCodeBusy, manualCodeWaiting, @@ -13,14 +12,12 @@ export function AddCodexAccountWaitingStep({ statusTone, flowId, error, - onCopyLoginLink, onManualCodeChange, onSubmitManualCode, onClose, }: { reauthAccountId?: string; authUrl: string; - copied: boolean; manualCode: string; manualCodeBusy: boolean; manualCodeWaiting: boolean; @@ -28,7 +25,6 @@ export function AddCodexAccountWaitingStep({ statusTone: StatusTone; flowId: string | null; error: string; - onCopyLoginLink: () => void; onManualCodeChange: (value: string) => void; onSubmitManualCode: () => void; onClose: () => void; @@ -39,9 +35,7 @@ export function AddCodexAccountWaitingStep({ <>

{reauthAccountId ? t("codexAuth.reauthenticate") : t("codexAuth.oauthLogin")}

{t("codexAuth.oauthWaiting")}

- +
{t("prov.pasteRedirectHint")}
diff --git a/gui/src/components/use-add-codex-account-oauth.ts b/gui/src/components/use-add-codex-account-oauth.ts index 00394bd542..c549a29034 100644 --- a/gui/src/components/use-add-codex-account-oauth.ts +++ b/gui/src/components/use-add-codex-account-oauth.ts @@ -250,28 +250,6 @@ export function useAddCodexAccountOAuth({ void startOAuth(); }, [reauthAccountId, startOAuth]); - const copyLoginLink = async () => { - if (!ui.authUrl) return; - try { - if (navigator.clipboard?.writeText) { - await navigator.clipboard.writeText(ui.authUrl); - } else { - const input = document.createElement("textarea"); - input.value = ui.authUrl; - input.style.opacity = "0"; - input.style.position = "fixed"; - document.body.appendChild(input); - input.select(); - document.execCommand("copy"); - document.body.removeChild(input); - } - dispatch({ type: "set-copied", copied: true }); - setTimeout(() => { if (aliveRef.current) dispatch({ type: "set-copied", copied: false }); }, 2500); - } catch { - dispatch({ type: "set-error", error: t("codexAuth.loginLinkCopyFailed") }); - } - }; - const submitManualCode = useCallback(async () => { const flowId = flowRef.current; const input = ui.manualCode.trim(); @@ -308,7 +286,6 @@ export function useAddCodexAccountOAuth({ bindCallbacks, closeModal, startOAuth, - copyLoginLink, submitManualCode, }; } diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 7ab03ba8cb..aa0935c3b2 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -687,9 +687,6 @@ export const de: Record = { "codexAuth.oauthSubmittingCode": "Code wird gesendet…", "codexAuth.oauthCodeSubmitted": "Code gesendet — warte auf Abschluss der Anmeldung…", "codexAuth.oauthStatusRetrying": "Beim Prüfen des Login-Status ist ein Netzwerk- oder Proxyfehler aufgetreten — erneuter Versuch…", - "codexAuth.copyLoginLink": "Login-Link kopieren", - "codexAuth.loginLinkCopied": "Login-Link kopiert", - "codexAuth.loginLinkCopyFailed": "Login-Link konnte nicht kopiert werden.", "codexAuth.oauthCancelled": "Login wurde abgebrochen.", "codexAuth.loginFailed": "Login fehlgeschlagen", "codexAuth.needsReauth": "Erneut anmelden", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index aec443f714..b56c3ac026 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1031,9 +1031,6 @@ export const en = { "codexAuth.oauthSubmittingCode": "Submitting code…", "codexAuth.oauthCodeSubmitted": "Code submitted — waiting for login to finish…", "codexAuth.oauthStatusRetrying": "Network or proxy error while checking login status — retrying…", - "codexAuth.copyLoginLink": "Copy login link", - "codexAuth.loginLinkCopied": "Login link copied", - "codexAuth.loginLinkCopyFailed": "Could not copy login link.", "codexAuth.oauthCancelled": "Login was cancelled.", "codexAuth.loginFailed": "Login failed", "codexAuth.needsReauth": "Re-login", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 6e449d4ed6..fc43a5fe94 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -985,9 +985,6 @@ export const ja: Record = { "codexAuth.oauthSubmittingCode": "コードを送信中…", "codexAuth.oauthCodeSubmitted": "コードを送信しました — ログイン完了を待っています…", "codexAuth.oauthStatusRetrying": "ログイン状態の確認中にネットワークまたはプロキシ エラーが発生しました — 再試行中…", - "codexAuth.copyLoginLink": "ログインリンクをコピー", - "codexAuth.loginLinkCopied": "ログインリンクをコピーしました", - "codexAuth.loginLinkCopyFailed": "ログインリンクをコピーできませんでした。", "codexAuth.oauthCancelled": "ログインはキャンセルされました。", "codexAuth.loginFailed": "ログインに失敗しました", "codexAuth.needsReauth": "再ログイン", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index cf452111c5..a92b88411b 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -704,9 +704,6 @@ export const ko: Record = { "codexAuth.oauthSubmittingCode": "코드를 제출 중…", "codexAuth.oauthCodeSubmitted": "코드를 제출했습니다 — 로그인 완료를 기다리는 중입니다…", "codexAuth.oauthStatusRetrying": "로그인 상태를 확인하는 중 네트워크 또는 프록시 오류가 발생했습니다 — 재시도 중…", - "codexAuth.copyLoginLink": "로그인 링크 복사", - "codexAuth.loginLinkCopied": "로그인 링크를 복사했습니다", - "codexAuth.loginLinkCopyFailed": "로그인 링크를 복사하지 못했습니다.", "codexAuth.oauthCancelled": "로그인이 취소되었습니다.", "codexAuth.loginFailed": "로그인에 실패했습니다", "codexAuth.needsReauth": "재로그인", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index a15c79fc45..fdd9e18f6c 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1030,9 +1030,6 @@ export const ru: Record = { "codexAuth.oauthSubmittingCode": "Отправка кода…", "codexAuth.oauthCodeSubmitted": "Код отправлен — ждём завершения входа…", "codexAuth.oauthStatusRetrying": "При проверке статуса входа возникла сетевая ошибка или ошибка прокси — повторяем…", - "codexAuth.copyLoginLink": "Копировать ссылку для входа", - "codexAuth.loginLinkCopied": "Ссылка для входа скопирована", - "codexAuth.loginLinkCopyFailed": "Не удалось скопировать ссылку для входа.", "codexAuth.oauthCancelled": "Вход отменён.", "codexAuth.loginFailed": "Не удалось войти", "codexAuth.needsReauth": "Повторный вход", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index ff06b30a2b..c740270098 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -704,9 +704,6 @@ export const zh: Record = { "codexAuth.oauthSubmittingCode": "正在提交代码…", "codexAuth.oauthCodeSubmitted": "代码已提交——正在等待登录完成…", "codexAuth.oauthStatusRetrying": "检查登录状态时发生网络或代理错误——正在重试…", - "codexAuth.copyLoginLink": "复制登录链接", - "codexAuth.loginLinkCopied": "登录链接已复制", - "codexAuth.loginLinkCopyFailed": "无法复制登录链接。", "codexAuth.oauthCancelled": "登录已取消。", "codexAuth.loginFailed": "登录失败", "codexAuth.needsReauth": "重新登录", diff --git a/gui/tests/add-codex-account-login-url.test.tsx b/gui/tests/add-codex-account-login-url.test.tsx new file mode 100644 index 0000000000..99204a1524 --- /dev/null +++ b/gui/tests/add-codex-account-login-url.test.tsx @@ -0,0 +1,145 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import { LanguageProvider } from "../src/i18n/provider"; +import AddCodexAccountModal from "../src/components/AddCodexAccountModal"; + +/** + * The Codex account add/reauth modal was the last login surface without a way + * to obtain the authorization URL: it rendered a copy button and nothing else, + * so a blocked clipboard left the user with no path forward, and a copy failure + * surfaced in the login error channel. + */ + +const AUTH_URL = "https://auth.openai.test/oauth/authorize?client_id=codex&state=zzz"; + +const globals = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previous: Record<(typeof globals)[number], unknown>; +let win: Window; +let host: HTMLElement; +let root: Root | null = null; +let originalFetch: typeof globalThis.fetch; +let clipboardWrites: string[] = []; +let statusHolders: Array<{ resolve: (value: Response) => void }> = []; + +function installClipboard(available: boolean) { + clipboardWrites = []; + Object.defineProperty(win.navigator, "clipboard", { + configurable: true, + value: available + ? { writeText: async (text: string) => { clipboardWrites.push(text); } } + : undefined, + }); +} + +beforeEach(() => { + previous = Object.fromEntries(globals.map((k) => [k, Reflect.get(globalThis, k)])) as typeof previous; + win = new Window({ url: "http://localhost/" }); + Object.defineProperty(win.navigator, "language", { configurable: true, value: "en-US" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: win.document }, + window: { configurable: true, value: win }, + navigator: { configurable: true, value: win.navigator }, + localStorage: { configurable: true, value: win.localStorage }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + installClipboard(true); + + originalFetch = globalThis.fetch; + statusHolders = []; + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: async (input: RequestInfo | URL) => { + const url = new URL(String(input), "http://localhost"); + if (url.pathname === "/api/codex-auth/login") { + return Response.json({ url: AUTH_URL, flowId: "flow-1" }); + } + if (url.pathname === "/api/codex-auth/login-status") { + return await new Promise((resolve) => { statusHolders.push({ resolve }); }); + } + return Response.json({}); + }, + }); + + host = win.document.createElement("div") as unknown as HTMLElement; + win.document.body.appendChild(host as never); +}); + +afterEach(async () => { + for (const holder of statusHolders.splice(0)) { + holder.resolve(Response.json({ status: "pending" })); + } + if (root) { + const current = root; + await act(async () => { current.unmount(); }); + root = null; + } + await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previous[key] }); + } + Object.defineProperty(globalThis, "fetch", { configurable: true, value: originalFetch }); + await win.happyDOM?.close?.(); +}); + +/** Reauth enters the waiting step immediately, but authUrl arrives a tick later. */ +async function mountReauthModal() { + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root = createRoot(host); + root.render( + + {}} onAdded={() => {}} reauthAccountId="acct-1" /> + , + ); + }); + await act(async () => { await new Promise((r) => setTimeout(r, 40)); }); +} + +function copyButton(): HTMLButtonElement { + const button = host.querySelector(".pwi-auth-url-actions button"); + expect(button).toBeTruthy(); + return button as HTMLButtonElement; +} + +test("the waiting step exposes the authorization URL and a manual open link", async () => { + await mountReauthModal(); + + expect(host.textContent).toContain(AUTH_URL); + + const anchor = Array.from(host.querySelectorAll("a")).find( + (el) => el.getAttribute("href") === AUTH_URL, + ); + expect(anchor).toBeTruthy(); + expect(anchor?.getAttribute("target")).toBe("_blank"); + expect(host.textContent).toContain("Didn't open?"); +}); + +test("copying the login URL reports success on the button, not in a notice", async () => { + await mountReauthModal(); + + await act(async () => { + copyButton().dispatchEvent(new win.MouseEvent("click", { bubbles: true })); + await new Promise((r) => setTimeout(r, 0)); + }); + + expect(clipboardWrites).toEqual([AUTH_URL]); + expect(host.textContent).toContain("Copied"); +}); + +test("a blocked clipboard never reaches the login error channel", async () => { + installClipboard(false); + await mountReauthModal(); + + await act(async () => { + copyButton().dispatchEvent(new win.MouseEvent("click", { bubbles: true })); + await new Promise((r) => setTimeout(r, 0)); + }); + + expect(host.textContent).toContain("Clipboard unavailable"); + // A copy failure used to dispatch set-error, mixing it with login failures. + expect(host.querySelector(".notice-err")).toBeNull(); + // The URL stays selectable, so the user still has a recovery path. + expect(host.textContent).toContain(AUTH_URL); +}); From 14dcd2f30d87411f2628874890829b4aeb65dd18 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Mon, 27 Jul 2026 16:45:34 +0900 Subject: [PATCH 05/13] refactor(gui): fold the two duplicated login-URL blocks into the shared one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workspace 패널과 프로바이더 추가 모달이 같은 블록을 두 벌 들고 있었다. 상태 이름, 타이머 ref, 렌더 트리, 클래스명까지 같았다. 다음 회귀가 세 갈래로 갈릴 준비를 하고 있던 셈이라 소유자를 하나로 접는다. 두 파일에서 linkCopyState, linkCopyTimer, 복사 핸들러, 정리 useEffect가 사라지고 LoginUrlBlock 한 줄이 남는다. didntOpen 링크도 블록이 가져간다 — 'URL을 얻는다'는 하나의 의도에 속하는 조각이라 따로 놓을 이유가 없다. 프로바이더 추가 모달의 oauthBusy 렌더 조건과 reducer의 oauthUrlProvider 경쟁 가드는 건드리지 않았다. A/B 로그인이 겹칠 때 늦게 온 응답이 맞는 URL을 지우지 못하게 막는 계약이다. 이관이 동등하다는 증거는 기존 테스트 두 파일이다. 한 줄도 고치지 않고 9건 전부 통과한다. 검증: gui 313 pass 0 fail, 루트 4972 pass 0 fail, typecheck/tsc -b/lint/ lint:i18n/privacy:scan 전부 통과. --- .../components/add-provider-oauth-pane.tsx | 41 ++---------------- .../provider-workspace/ProviderAuthPanel.tsx | 42 ++----------------- 2 files changed, 7 insertions(+), 76 deletions(-) diff --git a/gui/src/components/add-provider-oauth-pane.tsx b/gui/src/components/add-provider-oauth-pane.tsx index 7f849ff015..4003ca0177 100644 --- a/gui/src/components/add-provider-oauth-pane.tsx +++ b/gui/src/components/add-provider-oauth-pane.tsx @@ -1,7 +1,6 @@ -import { useEffect, useRef, useState } from "react"; -import { IconExternal, IconLink, IconLock } from "../icons"; +import { IconLock } from "../icons"; import { useT } from "../i18n/shared"; -import { copyTextToClipboard } from "../oauth-health-display"; +import { LoginUrlBlock } from "./login-url-block"; import type { CatalogPreset } from "./provider-catalog/provider-presets"; export function AddProviderOAuthPane({ @@ -38,21 +37,6 @@ export function AddProviderOAuthPane({ onBack: () => void; }) { const t = useT(); - const [linkCopyState, setLinkCopyState] = useState<"idle" | "copied" | "unavailable">("idle"); - const linkCopyTimer = useRef | null>(null); - - useEffect(() => () => { if (linkCopyTimer.current) clearTimeout(linkCopyTimer.current); }, []); - - const copyAuthUrl = () => { - void copyTextToClipboard(oauthUrl).then((ok) => { - setLinkCopyState(ok ? "copied" : "unavailable"); - if (linkCopyTimer.current) clearTimeout(linkCopyTimer.current); - linkCopyTimer.current = setTimeout(() => { - linkCopyTimer.current = null; - setLinkCopyState("idle"); - }, 2500); - }); - }; return (
@@ -72,26 +56,7 @@ export function AddProviderOAuthPane({ {oauthMsg}
)} - {oauthBusy && oauthUrl && ( -
- {oauthUrl} -
- - - -
-
- )} + {oauthBusy && } {oauthBusy && (
diff --git a/gui/src/components/provider-workspace/ProviderAuthPanel.tsx b/gui/src/components/provider-workspace/ProviderAuthPanel.tsx index 3c24f95254..bd9ebd2d48 100644 --- a/gui/src/components/provider-workspace/ProviderAuthPanel.tsx +++ b/gui/src/components/provider-workspace/ProviderAuthPanel.tsx @@ -3,9 +3,9 @@ * embedding for the workspace Settings tab (WP091). Consumes WP040+WP060 * handlers via props-down; no internal auth machinery. */ -import { useEffect, useRef, useState } from "react"; +import { useState } from "react"; import { useT } from "../../i18n/shared"; -import { IconLock, IconExternal, IconLink, IconTrash } from "../../icons"; +import { IconLock, IconTrash } from "../../icons"; import type { WorkspaceItem } from "../../provider-workspace/catalog"; import { oauthAccountDisplayLabel, providerAuthSurface } from "../../provider-workspace/auth"; import { displayAccountId } from "../../lib/privacy"; @@ -21,6 +21,7 @@ import { type DoctorCopyFeedback, } from "../../oauth-health-display"; import CodexAccountPool from "../CodexAccountPool"; +import { LoginUrlBlock } from "../login-url-block"; import type { CodexAccountPoolController } from "../../hooks/useCodexAccountPool"; import type { AccountLoadState, OAuthAccountRow, ApiKeyRow, LoginHint, ProviderAuthHandlers } from "./types"; @@ -50,21 +51,6 @@ export default function ProviderAuthPanel({ const [newKey, setNewKey] = useState(""); const [keyBusy, setKeyBusy] = useState(false); const [deviceCodeCopied, setDeviceCodeCopied] = useState(false); - const [linkCopyState, setLinkCopyState] = useState<"idle" | "copied" | "unavailable">("idle"); - const linkCopyTimer = useRef | null>(null); - - useEffect(() => () => { if (linkCopyTimer.current) clearTimeout(linkCopyTimer.current); }, []); - - const copyLoginUrl = (url: string) => { - void copyTextToClipboard(url).then((ok) => { - setLinkCopyState(ok ? "copied" : "unavailable"); - if (linkCopyTimer.current) clearTimeout(linkCopyTimer.current); - linkCopyTimer.current = setTimeout(() => { - linkCopyTimer.current = null; - setLinkCopyState("idle"); - }, 2500); - }); - }; const [copiedDoctorFor, setCopiedDoctorFor] = useState(null); const surface = providerAuthSurface({ ...item, hasApiKey: item.hasApiKey || keys.length > 0 }); @@ -152,27 +138,7 @@ export default function ProviderAuthPanel({ }}>{deviceCodeCopied ? t("prov.codeCopied") : t("prov.copyCode")}
)} - {hintForThis.url && ( -
- {hintForThis.url} -
- - - {t("prov.didntOpen")} - -
-
- )} + {authHandlers.onCancelLogin && ( +``` + +같은 대기 패널 안에서 규약이 둘로 갈린다. + +| | 기기 코드 복사 | 로그인 URL 복사 | +|---|---|---| +| 구현 | `navigator.clipboard.writeText` 직접 | `copyTextToClipboard` | +| 비보안 컨텍스트 | **TypeError로 즉시 죽음** | execCommand 폴백 | +| 실패 처리 | `.catch(() => {})` 침묵 | `unavailable` 라벨 | +| 타이머 | ref 없는 `setTimeout` | ref + clearTimeout | + +비보안 컨텍스트 문제가 특히 나쁘다. `navigator.clipboard`가 `undefined`면 +`.writeText`를 읽는 순간 TypeError가 나고, `.catch()`는 **Promise 거부만** +잡으므로 이 동기 예외를 못 잡는다. LAN 바인딩(`hostname: 0.0.0.0`, 평문 HTTP)에서 +기기 코드 복사 버튼을 누르면 아무 일도 일어나지 않고 콘솔에만 예외가 남는다. +a19ce5dd가 URL 복사에 대해 고친 바로 그 문제가 여기 그대로 있다. + +## A 감사가 바꾼 방향 — 훅으로 규약을 실제로 공유한다 + +초안은 기기 코드에 3-상태를 손으로 한 벌 더 짜는 계획이었다. 감사가 +정확히 반박했다: "공유하는 것은 복사 규약"이라 써놓고 규약을 공유하지 않으면 +같은 프로토콜의 손수 구현이 **셋**이 된다. + +| 표면 | 상태 모양 | 라벨 | 타이머 | +|------|-----------|------|--------| +| `LoginUrlBlock` | `{url, outcome} \| null` | 인라인 삼항 | `timerRef` | +| 기기 코드 (초안대로면) | `"idle"\|"copied"\|"unavailable"` | 인라인 삼항 | 또 다른 ref | +| doctor 복사 | `DoctorCopyFeedback{accountId, outcome}` | `doctorCopyButtonLabel` | ref 없는 `setTimeout` | + +게다가 doctor 복사(`ProviderAuthPanel.tsx:182`)에는 **URL 복사가 이미 겪은 +타이머 회귀가 그대로 남아 있다**. functional update로 `accountId`+`outcome`을 +비교해 완화했지만, 같은 계정을 같은 결과로 연속 클릭하면 앞 타이머가 뒤 +피드백을 지운다. 5919779d 감사가 "functional update 가드는 불충분하다"고 +결론 낸 바로 그 형태다. + +세 번째 손수 구현을 추가하는 대신 훅을 뽑는다. 셋 다 이미 스코프 키를 갖고 +있다 — URL, (없음), accountId. 하나의 추상이 자연스럽게 맞는다. + +### 신규 `gui/src/components/use-copy-feedback.ts` + +```ts +export type CopyOutcome = "copied" | "unavailable"; + +export function useCopyFeedback(): { + outcomeFor: (scope: K) => CopyOutcome | null; + copy: (text: string, scope: K) => void; +}; +``` + +- 피드백을 `{ scope, outcome }`로 들고, 읽을 때 스코프가 일치할 때만 결과를 + 돌려준다. `LoginUrlBlock`이 url 변경 시 자동으로 idle이 되는 그 방식과 동일하다 + (`react-hooks/set-state-in-effect`가 effect 리셋을 금지하므로 파생이 유일한 길). +- 타이머는 훅이 ref로 소유한다. 재클릭 시 `clearTimeout`, 언마운트 시 정리. +- `copyTextToClipboard`를 부른다 — execCommand 폴백이 세 표면 모두에 붙는다. +- 스코프가 없는 표면은 `K = void`로 쓴다. + +### 세 소비처 이관 + +- `LoginUrlBlock`: 지역 `useState`/`timerRef`/`copy`를 훅 호출로 교체. 스코프는 `url`. +- 기기 코드: 훅 호출. 스코프 없음. 라벨은 아래 표. +- doctor 복사: `copiedDoctorFor`/`setTimeout`을 훅으로 교체. 스코프는 `accountId`. + `doctorCopyButtonLabel`은 `DoctorCopyFeedback` 대신 `CopyOutcome | null`을 받도록 + 좁힌다 — 스코프 비교는 이제 훅이 한다. + `CodexAccountPool.tsx`와 `codex-account-pool-cards.tsx`도 같은 시그니처로 따라간다. + +## 변경 — `ProviderAuthPanel.tsx` + +기기 코드 복사도 URL 복사와 같은 3-상태를 갖는다. 코드 전문이 ``에 +이미 보이므로 `unavailable`은 dead-end가 아니다 — `.pwi-device-code`가 +`user-select: all`이라 수동 선택 경로가 화면에 남아 있다(css:33). + +- `deviceCodeCopied: boolean` 상태와 인라인 `setTimeout` 제거 → `useCopyFeedback()`. +- 타이머·폴백·스코프는 전부 훅이 소유한다. +- 라벨: `idle` → `prov.copyCode`, `copied` → `prov.codeCopied`, + `unavailable` → `prov.linkCopyUnavailable` 재사용. + 새 i18n 키를 만들지 않는다. "클립보드를 사용할 수 없음"은 복사 대상이 + 링크든 코드든 같은 사실을 말하고, 6개 로케일 문안도 대상 중립적이다 + (en "Clipboard unavailable", ko "클립보드를 사용할 수 없음"). +- 라벨 span에 `aria-live="polite"`. URL 복사와 같은 고지 규약. + +`LoginUrlBlock`으로 흡수하지 않는다. 그 컴포넌트는 URL이라는 하나의 대상에 +묶인 URL 전문 + 외부 열기 링크를 함께 소유한다. 기기 코드는 외부 링크가 없고 +표시 형태(`.pwi-device-code`, 큰 자간 강조)도 다르다. 억지로 합치면 props가 +분기되어 소유의 이점이 사라진다. 공유하는 것은 **복사 규약**이고, 그 규약은 +위 훅이 소유한다 — 렌더가 아니라 동작을 공유하는 것이 이 분리의 요점이다. + +## i18n — ja 미번역 3건 (A 감사 #2) + +`gui/src/i18n/ja.ts:1310-1312`의 `prov.deviceCode` / `prov.copyCode` / +`prov.codeCopied`가 영어 그대로다. `sync-locale-keys.mjs`가 키를 채울 때 +영문을 붙여두고 파일 맨 아래에 남긴 것으로, `prov.*` 블록에서도 떨어져 있다. +이 사이클이 기기 코드 복사 라벨을 건드리므로 함께 번역한다. + +- `"prov.deviceCode": "デバイスコード"` +- `"prov.copyCode": "コードをコピー"` +- `"prov.codeCopied": "コードをコピーしました"` + +위치도 `prov.*` 인접 블록(`ja.ts:248` 근처)으로 옮겨 다른 로케일과 같은 배치로 +맞춘다. 어떤 게이트도 이걸 잡지 못한다 — `lint:i18n`은 `src/i18n/**`를 +globalIgnores로 빼고, `claude-desktop-locale.test.ts`는 키 **집합**만 보지 +번역 여부는 보지 않는다. 사람이 보지 않으면 영원히 영어로 남는다. + +`prov.linkCopyUnavailable`은 6개 로케일 전부 대상 중립적이라 +(en "Clipboard unavailable", ja "クリップボードを使用できません", +de "Zwischenablage nicht verfügbar") 코드 복사에 재사용해도 어색하지 않다. +감사가 6개 전부 확인했다. + +## 회귀 테스트 — `gui/tests/provider-auth-device-code-copy.test.tsx` + +`provider-auth-login-copy-link.test.tsx`의 하네스를 그대로 쓴다 +(`loginHint`에 `deviceCode`를 넣으면 같은 대기 패널이 렌더된다). + +마운트 조건(감사가 실측): `busy`가 참이고 `loginHint.provider === item.name`, +`item.authMode === "oauth"`. `url`은 선택이므로 `deviceCode`만 주면 기기 코드 +블록만 렌더된다. + +**하네스를 그대로 복사하면 안 된다(A 감사 blocker #4).** happy-dom에는 +`document.execCommand`가 없다(`typeof`가 `undefined`). 클립보드 스텁만 있는 +기존 하네스를 복사하면 폴백 케이스가 조용히 unavailable 경로를 타고 **틀린 +이유로 통과한다**. `gui/tests/clipboard-fallback.test.ts:20`처럼 +`Object.defineProperty(win.document, "execCommand", ...)` 헬퍼를 함께 둔다. + +1. 기기 코드 전문이 렌더되고 복사 버튼이 그 코드를 클립보드에 넣는다. +2. 클립보드 API 부재 + execCommand 스텁 → 폴백으로 복사되고 라벨이 + `prov.codeCopied`가 된다. +3. 둘 다 부재 → 라벨이 `prov.linkCopyUnavailable`이 된다. + **`expect(() => click()).not.toThrow()`로 쓰지 않는다** — React 19가 동기 + 예외를 dev 에러 경로로 흘려보내 happy-dom과 섞이면 단언이 불안정하다. + "라벨이 unavailable이 된다"는 긍정 단언이 오늘 도달 불가능하므로 그것으로 + 충분한 가드다. +4. 2.5초 창 안에서 재클릭해도 뒤 클릭 피드백이 제 수명을 다한다(훅 타이머). +5. doctor 복사도 같은 재클릭 회귀를 갖지 않는다 — 같은 계정·같은 결과로 + 연속 클릭해도 뒤 피드백이 살아남는다(훅 이관의 실질 이득). + +버튼은 `.pwi-device-code-wrap button`으로 구조 조회한다. + +## 검증 + +- `bun run typecheck` exit 0 (루트 — AGENTS.md 요구) +- `cd gui && bun x tsc -b` exit 0, `bun run lint:gui` exit 0 +- `cd gui && bun test tests` 전건 통과 +- `bun run test` (루트) 신규 실패 0 — `tests/provider-workspace-auth.test.ts:160`은 + `pwi-device-code` 부분 문자열 매치라 이 사이클이 바꾸는 것을 제약하지 않는다 + (감사 #5: 게이트가 아니라 무영향) +- `bun run privacy:scan` 통과 +- 변경을 되돌리면 신규 테스트 3·4·5번이 실패한다 diff --git a/devlog/_plan/260727_login_url_copy_parity/060_style_ownership.md b/devlog/_plan/260727_login_url_copy_parity/060_style_ownership.md new file mode 100644 index 0000000000..95280fbd81 --- /dev/null +++ b/devlog/_plan/260727_login_url_copy_parity/060_style_ownership.md @@ -0,0 +1,115 @@ +# 060 — 로그인 URL 블록 스타일의 소속 정리 (wp2) + +040의 "남은 항목" 둘째. + +## 문제 + +`LoginUrlBlock`은 세 표면이 공유하는 컴포넌트인데, 그 스타일은 이름도 파일도 +한 표면의 소유로 남아 있다. + +- 클래스: `.pwi-auth-url-wrap` / `.pwi-auth-url` / `.pwi-auth-url-actions` / + `.pwi-auth-open-link`. `pwi`는 provider **w**orkspace **i**tem이다. +- 파일: `gui/src/styles/provider-workspace-settings.css:36-39`. + 헤더 주석이 "WP091 — provider workspace settings / auth / JSON editor"이고 + `─ ProviderAuthPanel ─` 섹션 아래 있다. + +실제 소비처는 셋이다: 워크스페이스 패널, 프로바이더 추가 모달, Codex 계정 모달. +뒤 둘은 워크스페이스가 아니다. 지금은 `styles.css`가 모든 부분 스타일시트를 +전역 import(`styles.css:13`)해서 동작하지만, 이름이 소속을 속이고 있어 +다음 사람이 워크스페이스 CSS를 정리할 때 모달을 깨뜨릴 수 있다. + +## 컨벤션 근거 + +이 저장소는 이미 컴포넌트/기능 단위 스타일 파일 + 그 이름을 딴 접두사를 쓴다. +`gui/src/styles/provider-catalog.css`가 `.provider-catalog*`를 소유하는 식이다. +새 규칙을 만드는 게 아니라 기존 관행을 따른다. + +## 변경 1 — 신규 `gui/src/styles/login-url-block.css` + +`provider-workspace-settings.css`의 네 규칙(`.pwi-auth-open-link`가 `:36`, +나머지 셋이 `:37-39`)을 옮기고 재명명한다. + +| 이전 | 이후 | +|------|------| +| `.pwi-auth-url-wrap` | `.login-url-block` | +| `.pwi-auth-url` | `.login-url-block-text` | +| `.pwi-auth-url-actions` | `.login-url-block-actions` | +| `.pwi-auth-open-link` | `.login-url-block-open` | + +접두사를 컴포넌트/파일 이름과 정확히 일치시킨다(A 감사 #7). 초안의 `.login-url`은 +저장소 관행(`.provider-catalog*`, `.quota-*` 같은 기능 스코프 접두사)에 비해 너무 +일반적이라 나중에 충돌 여지를 남긴다. 지금 붙여두면 비용이 0이다. +현재 `gui/src`의 9개 CSS 파일 어디에도 `.login*` 셀렉터가 없음을 감사가 확인했다. + +속성값은 한 글자도 바꾸지 않는다. 이번 사이클은 소속 정리이지 시각 변경이 +아니다. 렌더 결과가 달라지면 그건 실패다. + +`.pwi-auth-open-link`는 `LoginUrlBlock` 밖에 소비처가 없다(rg로 확인). +따라서 함께 옮긴다. + +## 변경 2 — `gui/src/styles.css` + +`@import "./styles/login-url-block.css";`를 추가한다. 삽입 위치는 +`provider-workspace-settings.css` 다음 줄 — 기존 import 블록의 인접성을 유지한다. + +**import 순서는 결과에 영향이 없다(A 감사 #8).** 네 규칙은 전부 단일 클래스 +선택자(명시도 0-1-0)이고, `provider-workspace-settings.css`에 `@media`도 +`!important`도 없으며, 어떤 GUI 스타일시트도 같은 요소를 같은 명시도로 겨루지 +않는다. 경쟁 선택자가 없으므로 소스 순서가 캐스케이드를 바꿀 수 없다. +인접 배치는 가독성 때문이지 정확성 때문이 아니다. + +## 변경 3 — `gui/src/components/login-url-block.tsx` + +네 개의 `className`을 새 이름으로 교체. 구조·속성 변경 없음. + +## 변경 4 — 테스트 셀렉터 4파일 + +| 파일 | 셀렉터 | +|------|--------| +| `gui/tests/login-url-block.test.tsx:76,152` | `.pwi-auth-url-actions button`, `.pwi-auth-url-wrap` | +| `gui/tests/provider-auth-login-copy-link.test.tsx:104` | `.pwi-auth-url-actions button` | +| `gui/tests/add-codex-account-login-url.test.tsx:101` | `.pwi-auth-url-actions button` | +| `gui/tests/add-provider-oauth-url-leak.test.tsx:122,138,167` | `.pwi-auth-url` | + +**셀렉터만 바꾸고 단언은 한 글자도 바꾸지 않는다.** 이것이 이 사이클의 +안전 조건이다. 단언이 함께 바뀌면 "리네임이 렌더를 보존했다"는 증거가 사라진다. +커밋 diff에서 테스트 변경이 전부 셀렉터 문자열 한 줄짜리인지 눈으로 확인한다. + +여기에 단언 하나를 **추가**한다(A 감사 #9). `login-url-block.test.tsx`에 +렌더된 래퍼의 클래스가 `login-url-block`임을 단언한다. 리네임 자체에는 동작 +변화가 없어 테스트가 없으면 "소속을 정리했다"는 주장이 검증 불가능한 신념으로 +남는다. 이 한 줄이 그 주장을 집행 가능하게 만든다. + +## 건드리지 않는 것 + +- `.pwi-device-code*`: `ProviderAuthPanel` 전용이고 다른 표면에 없다. + 이름과 소속이 일치하므로 그대로 둔다. +- `.pwi-auth-wait` / `.pwi-auth-wait-copy` / `.pwi-auth-section` 등 나머지 + `pwi-` 계열 전부. 워크스페이스 패널 전용이다. +- `tests/provider-workspace-auth.test.ts:160`의 `pwi-device-code` 단언. + +즉 이 사이클은 `pwi-` 접두사를 일괄 개명하는 작업이 **아니다**. 소속이 +어긋난 네 개만 옮긴다. + +## 검증 + +- `bun run typecheck` exit 0 (루트) +- `cd gui && bun x tsc -b` exit 0, `bun run lint:gui` exit 0 +- `cd gui && bun test tests` 전건 통과 — 특히 위 4파일이 셀렉터 갱신만으로 통과 +- `rg "pwi-auth-url|pwi-auth-open-link" gui/` → 0건 +- `rg 'className="login-url' gui/src` → 컴포넌트 4건 (파일명 매치가 섞이는 + `rg "login-url-"`은 게이트로 쓰지 않는다 — 이미 import 3건이 걸린다, A 감사 #10) +- `rg "login-url-block" gui/src/styles/login-url-block.css` → CSS 4건 +- `bun run build:gui` 성공(CSS import 경로 확인) +- `bun run test` (루트) 신규 실패 0, `bun run privacy:scan` 통과 + +## 사이클 배치 (A 감사 #9) + +감사는 060을 050의 꼬리 커밋으로 접으라고 권했다 — 동작 변화가 없는 커밋 +하나만으로 별도 리뷰 라운드를 도는 건 값이 안 맞는다는 것이다. 절반만 받는다. + +**커밋은 분리하되 work-phase는 050과 같은 사이클에 두지 않는다.** 리네임은 +7개 파일의 셀렉터를 동시에 건드리므로, 기기 코드 복사 변경과 한 커밋에 섞이면 +gui 테스트가 깨졌을 때 원인이 훅 이관인지 셀렉터 교체인지 분리할 수 없다. +별도 work-phase(wp2) + 별도 커밋으로 두되, 리뷰는 wp3의 최종 검증에서 +한 번에 받는다. 감사의 "리뷰 라운드 하나" 취지는 지키고 이등분 가능성은 살린다. From 68ca90ca89dcf6b2ad8dd74c790e5988799bd8ff Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Mon, 27 Jul 2026 17:32:10 +0900 Subject: [PATCH 09/13] fix(gui): give every copy affordance one honest protocol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기기 코드 복사 버튼이 LAN 바인딩에서 죽어 있었다. navigator.clipboard가 없으면 .writeText를 읽는 순간 동기 TypeError가 나는데, 붙어 있던 .catch(() => {})는 이미 만들어진 promise의 거부만 잡으므로 아무것도 못 잡는다. 평문 HTTP로 노출한 GUI에서 버튼을 눌러도 아무 일이 없고 콘솔에만 예외가 남았다. 3-상태를 손으로 한 벌 더 짜려다 방향을 바꿨다. 그러면 같은 프로토콜의 손수 구현이 셋이 된다 — URL 복사, 기기 코드, doctor 복사. 게다가 doctor 복사에는 URL 복사가 이미 겪은 타이머 회귀가 그대로 남아 있었다. functional update로 완화했지만 같은 계정을 같은 결과로 연속 클릭하면 앞 타이머가 뒤 피드백을 지운다. useCopyFeedback이 규약을 소유한다. 피드백은 자기 스코프를 들고 다녀 스코프가 달라지면 렌더 시점에 idle로 읽히고, 타이머는 훅이 ref로 들고 재클릭·언마운트에서 정리한다. 셋 다 이미 스코프 키를 갖고 있었다 — url, 없음, accountId. doctorCopyButtonLabel은 스코프 비교를 훅에 넘기고 결과만 문구로 옮기는 함수로 좁아진다. ja 로케일의 기기 코드 키 3개가 영어로 남아 있었다. sync-locale-keys가 영문을 붙여 파일 맨 아래에 두고 간 것으로, lint:i18n은 src/i18n을 무시하고 로케일 테스트는 키 집합만 보므로 어떤 게이트도 잡지 못한다. 번역하고 prov.* 블록 옆으로 옮겼다. 검증: gui 323 pass 0 fail, 루트 4985 pass 0 fail, typecheck/tsc -b/lint/ privacy:scan 통과. 훅의 clearTimer를 지우면 2건, unavailable 라벨을 지우면 1건 실패한다. --- gui/src/components/CodexAccountPool.tsx | 22 +-- .../components/codex-account-pool-cards.tsx | 7 +- .../codex-account-pool-main-card.tsx | 7 +- gui/src/components/login-url-block.tsx | 37 +--- .../provider-workspace/ProviderAuthPanel.tsx | 38 ++-- gui/src/components/use-copy-feedback.ts | 50 ++++++ gui/src/i18n/ja.ts | 6 +- gui/src/oauth-health-display.ts | 13 +- gui/tests/oauth-health-display.test.ts | 11 +- .../provider-auth-device-code-copy.test.tsx | 169 ++++++++++++++++++ 10 files changed, 264 insertions(+), 96 deletions(-) create mode 100644 gui/src/components/use-copy-feedback.ts create mode 100644 gui/tests/provider-auth-device-code-copy.test.tsx diff --git a/gui/src/components/CodexAccountPool.tsx b/gui/src/components/CodexAccountPool.tsx index 9e0027e36c..9031cb139f 100644 --- a/gui/src/components/CodexAccountPool.tsx +++ b/gui/src/components/CodexAccountPool.tsx @@ -15,7 +15,8 @@ import { CodexAccountResetModal } from "./codex-account-reset-modal"; import { CodexAccountPoolLoadStates, CodexAccountPoolMainCard, CodexAccountPoolPageHead } from "./codex-account-pool-main-card"; import { redeemResetCredit } from "./codex-account-pool-handlers"; import type { CodexAccountEntry } from "./codex-account-pool-types"; -import { accountNeedsReauth, copyTextToClipboard, type DoctorCopyFeedback } from "../oauth-health-display"; +import { accountNeedsReauth } from "../oauth-health-display"; +import { useCopyFeedback } from "./use-copy-feedback"; // Single definition lives with the controller that owns this data (WP3). export type { CodexAccountEntry } from "../hooks/useCodexAccountPool"; @@ -65,20 +66,11 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban const [redeeming, setRedeeming] = useState(false); const [creditDetails, setCreditDetails] = useState<{ granted_at: string; expires_at: string }[] | null>(null); const [creditDetailsLoading, setCreditDetailsLoading] = useState(false); - const [copiedDoctorFor, setCopiedDoctorFor] = useState(null); + const doctorCopy = useCopyFeedback(); const copyDoctor = useCallback((accountId: string) => { - void copyTextToClipboard(DOCTOR_CMD).then((ok) => { - const feedback: DoctorCopyFeedback = { - accountId, - outcome: ok ? "copied" : "unavailable", - }; - setCopiedDoctorFor(feedback); - setTimeout(() => setCopiedDoctorFor(current => ( - current?.accountId === accountId && current.outcome === feedback.outcome ? null : current - )), 2500); - }); - }, []); + doctorCopy.copy(DOCTOR_CMD, accountId); + }, [doctorCopy]); // The controller owns loading and polling. This surface only feeds the auto-switch // threshold observer and leases a pause while an OAuth modal is open. @@ -256,7 +248,7 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban onSwitch={setConfirm} onOpenReset={openResetPopup} onCopyDoctor={copyDoctor} - copiedDoctorFor={copiedDoctorFor} + doctorCopyOutcomeFor={doctorCopy.outcomeFor} />
@@ -285,7 +277,7 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban onEditAlias={editAlias} onRemove={remove} onCopyDoctor={copyDoctor} - copiedDoctorFor={copiedDoctorFor} + doctorCopyOutcomeFor={doctorCopy.outcomeFor} /> void; onRemove: (id: string) => void; onCopyDoctor?: (accountId: string) => void; - copiedDoctorFor?: DoctorCopyFeedback | null; + doctorCopyOutcomeFor?: (accountId: string) => "copied" | "unavailable" | null; }) { const t = useT(); const isNext = (id: string) => activeId === id; @@ -84,7 +83,7 @@ export function CodexAccountPoolCards({ )} {onCopyDoctor && oauthHealthShowsDoctor(healthStatus) && ( )} )} {t("codexAuth.appLogin")} diff --git a/gui/src/components/login-url-block.tsx b/gui/src/components/login-url-block.tsx index 1ccf5b67a4..698cfa47fe 100644 --- a/gui/src/components/login-url-block.tsx +++ b/gui/src/components/login-url-block.tsx @@ -1,11 +1,6 @@ -import { useCallback, useEffect, useRef, useState } from "react"; import { IconExternal, IconLink } from "../icons"; import { useT } from "../i18n/shared"; -import { copyTextToClipboard } from "../oauth-health-display"; - -const FEEDBACK_MS = 2500; - -type CopyOutcome = "copied" | "unavailable"; +import { useCopyFeedback } from "./use-copy-feedback"; /** * Recovery affordance for an OAuth waiting state: the proxy already tried to @@ -16,35 +11,11 @@ type CopyOutcome = "copied" | "unavailable"; */ export function LoginUrlBlock({ url }: { url: string }) { const t = useT(); - // Feedback carries the URL it belongs to. A new URL therefore reads as idle - // without an effect: the add-provider modal keeps this block mounted across a - // provider switch, and stale "copied" would claim a URL the clipboard never got. - const [feedback, setFeedback] = useState<{ url: string; outcome: CopyOutcome } | null>(null); - const timerRef = useRef | null>(null); - - const clearTimer = useCallback(() => { - if (timerRef.current) { - clearTimeout(timerRef.current); - timerRef.current = null; - } - }, []); - - useEffect(() => clearTimer, [clearTimer]); + const { outcomeFor, copy } = useCopyFeedback(); if (!url) return null; - const outcome = feedback?.url === url ? feedback.outcome : null; - - const copy = () => { - void copyTextToClipboard(url).then((ok) => { - clearTimer(); - setFeedback({ url, outcome: ok ? "copied" : "unavailable" }); - timerRef.current = setTimeout(() => { - timerRef.current = null; - setFeedback(null); - }, FEEDBACK_MS); - }); - }; + const outcome = outcomeFor(url); const label = outcome === "copied" ? t("prov.linkCopied") @@ -56,7 +27,7 @@ export function LoginUrlBlock({ url }: { url: string }) {
{url}
- diff --git a/gui/src/components/provider-workspace/ProviderAuthPanel.tsx b/gui/src/components/provider-workspace/ProviderAuthPanel.tsx index bd9ebd2d48..c1a5899710 100644 --- a/gui/src/components/provider-workspace/ProviderAuthPanel.tsx +++ b/gui/src/components/provider-workspace/ProviderAuthPanel.tsx @@ -10,7 +10,6 @@ import type { WorkspaceItem } from "../../provider-workspace/catalog"; import { oauthAccountDisplayLabel, providerAuthSurface } from "../../provider-workspace/auth"; import { displayAccountId } from "../../lib/privacy"; import { - copyTextToClipboard, doctorCopyButtonLabel, formatOAuthHealthLabel, formatOAuthHealthSummary, @@ -18,10 +17,10 @@ import { oauthHealthIsCooldown, oauthHealthShowsDoctor, oauthHealthShowsReauth, - type DoctorCopyFeedback, } from "../../oauth-health-display"; import CodexAccountPool from "../CodexAccountPool"; import { LoginUrlBlock } from "../login-url-block"; +import { useCopyFeedback } from "../use-copy-feedback"; import type { CodexAccountPoolController } from "../../hooks/useCodexAccountPool"; import type { AccountLoadState, OAuthAccountRow, ApiKeyRow, LoginHint, ProviderAuthHandlers } from "./types"; @@ -50,8 +49,8 @@ export default function ProviderAuthPanel({ const [addingKey, setAddingKey] = useState(false); const [newKey, setNewKey] = useState(""); const [keyBusy, setKeyBusy] = useState(false); - const [deviceCodeCopied, setDeviceCodeCopied] = useState(false); - const [copiedDoctorFor, setCopiedDoctorFor] = useState(null); + const deviceCodeCopy = useCopyFeedback(); + const doctorCopy = useCopyFeedback(); const surface = providerAuthSurface({ ...item, hasApiKey: item.hasApiKey || keys.length > 0 }); const isOauth = surface === "oauth-accounts"; @@ -76,6 +75,12 @@ export default function ProviderAuthPanel({ if (!surface || !authHandlers) return null; const hintForThis = loginHint?.provider === item.name ? loginHint : null; + const deviceCodeOutcome = deviceCodeCopy.outcomeFor(undefined); + const deviceCodeCopyLabel = deviceCodeOutcome === "copied" + ? t("prov.codeCopied") + : deviceCodeOutcome === "unavailable" + ? t("prov.linkCopyUnavailable") + : t("prov.copyCode"); const loggedIn = accounts.length > 0 || oauth?.loggedIn === true; const activeReauthAccount = accounts.find(a => a.active && a.needsReauth); const activeNeedsReauth = Boolean(activeReauthAccount); @@ -130,12 +135,10 @@ export default function ProviderAuthPanel({
{t("prov.deviceCode")} {hintForThis.deviceCode} - +
)} @@ -175,18 +178,7 @@ export default function ProviderAuthPanel({ const maskedId = displayAccountId(account.id); const healthLabel = formatOAuthHealthLabel(t, account.health); const healthSummary = formatOAuthHealthSummary(t, item.name, account.id, account.health); - const copyDoctor = () => { - void copyTextToClipboard(DOCTOR_CMD).then((ok) => { - const feedback: DoctorCopyFeedback = { - accountId: account.id, - outcome: ok ? "copied" : "unavailable", - }; - setCopiedDoctorFor(feedback); - setTimeout(() => setCopiedDoctorFor(current => ( - current?.accountId === account.id && current.outcome === feedback.outcome ? null : current - )), 2500); - }); - }; + const copyDoctor = () => { doctorCopy.copy(DOCTOR_CMD, account.id); }; return (
  • )} - +
  • diff --git a/gui/src/styles.css b/gui/src/styles.css index a9e1633ce3..29a04a56d6 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -11,6 +11,7 @@ @import "./styles/provider-quota.css"; @import "./styles/provider-workspace-shell.css"; @import "./styles/provider-workspace-settings.css"; +@import "./styles/login-url-block.css"; @import "./styles/provider-overview-dashboard.css"; @import "./styles-combos-workspace.css"; @import "./styles-models-workspace.css"; diff --git a/gui/src/styles/login-url-block.css b/gui/src/styles/login-url-block.css new file mode 100644 index 0000000000..dfc529c676 --- /dev/null +++ b/gui/src/styles/login-url-block.css @@ -0,0 +1,8 @@ +/* LoginUrlBlock — shared OAuth login-URL recovery affordance. + Used by the provider workspace panel, the add-provider modal, and the Codex + account modal, so these rules do not belong to any one of them. */ + +.login-url-block { display: flex; flex-direction: column; gap: 6px; margin: 6px 0; } +.login-url-block-text { display: block; max-width: 100%; overflow-wrap: anywhere; padding: 8px 10px; border: 1px solid var(--border); border-radius: var(--radius-xs); background: var(--surface); font-size: var(--text-label); color: var(--text); user-select: all; } +.login-url-block-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } +.login-url-block-open { font-size: var(--text-label); color: var(--accent); text-decoration: underline; cursor: pointer; } diff --git a/gui/src/styles/provider-workspace-settings.css b/gui/src/styles/provider-workspace-settings.css index 01f25670db..8da4fc5456 100644 --- a/gui/src/styles/provider-workspace-settings.css +++ b/gui/src/styles/provider-workspace-settings.css @@ -33,10 +33,6 @@ .pwi-device-code { font-size: 20px; font-weight: 800; letter-spacing: .14em; color: var(--text); user-select: all; } .pwi-auth-row-copy { min-width: 0; display: flex; flex-direction: column; align-items: flex-start; gap: 2px; } .pwi-auth-row-secondary { max-width: 100%; overflow: hidden; text-overflow: ellipsis; font-size: var(--text-label); color: var(--muted); } -.pwi-auth-open-link { font-size: var(--text-label); color: var(--accent); text-decoration: underline; cursor: pointer; } -.pwi-auth-url-wrap { display: flex; flex-direction: column; gap: 6px; margin: 6px 0; } -.pwi-auth-url { display: block; max-width: 100%; overflow-wrap: anywhere; padding: 8px 10px; border: 1px solid var(--border); border-radius: var(--radius-xs); background: var(--surface); font-size: var(--text-label); color: var(--text); user-select: all; } -.pwi-auth-url-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } .pwi-auth-list { display: flex; flex-direction: column; gap: 4px; margin: 0; padding: 0; list-style: none; } .pwi-auth-row { diff --git a/gui/tests/add-codex-account-login-url.test.tsx b/gui/tests/add-codex-account-login-url.test.tsx index 99204a1524..346d6fc012 100644 --- a/gui/tests/add-codex-account-login-url.test.tsx +++ b/gui/tests/add-codex-account-login-url.test.tsx @@ -98,7 +98,7 @@ async function mountReauthModal() { } function copyButton(): HTMLButtonElement { - const button = host.querySelector(".pwi-auth-url-actions button"); + const button = host.querySelector(".login-url-block-actions button"); expect(button).toBeTruthy(); return button as HTMLButtonElement; } diff --git a/gui/tests/add-provider-oauth-url-leak.test.tsx b/gui/tests/add-provider-oauth-url-leak.test.tsx index a1edd87bdf..9e0a04ecc5 100644 --- a/gui/tests/add-provider-oauth-url-leak.test.tsx +++ b/gui/tests/add-provider-oauth-url-leak.test.tsx @@ -119,7 +119,7 @@ test("an authorization URL arriving after a preset switch is never rendered", as }); expect(host.textContent).not.toContain(A_URL); - expect(host.querySelector(".pwi-auth-url")).toBeNull(); + expect(host.querySelector(".login-url-block-text")).toBeNull(); }); test("the in-flight provider's own authorization URL does render", async () => { @@ -135,7 +135,7 @@ test("the in-flight provider's own authorization URL does render", async () => { await new Promise((r) => setTimeout(r, 40)); }); - expect(host.querySelector(".pwi-auth-url")?.textContent).toBe(A_URL); + expect(host.querySelector(".login-url-block-text")?.textContent).toBe(A_URL); }); test("a late URL for an abandoned provider cannot overwrite the one already shown", async () => { @@ -164,6 +164,6 @@ test("a late URL for an abandoned provider cannot overwrite the one already show await new Promise((r) => setTimeout(r, 30)); }); - expect(host.querySelector(".pwi-auth-url")?.textContent).toBe(B_URL); + expect(host.querySelector(".login-url-block-text")?.textContent).toBe(B_URL); expect(host.textContent).not.toContain(A_URL); }); diff --git a/gui/tests/login-url-block.test.tsx b/gui/tests/login-url-block.test.tsx index 49f55e02b0..4d8f1ce0ab 100644 --- a/gui/tests/login-url-block.test.tsx +++ b/gui/tests/login-url-block.test.tsx @@ -73,7 +73,7 @@ async function render(url: string) { } function copyButton(): HTMLButtonElement { - const button = host.querySelector(".pwi-auth-url-actions button"); + const button = host.querySelector(".login-url-block-actions button"); expect(button).toBeTruthy(); return button as HTMLButtonElement; } @@ -146,9 +146,21 @@ test("offers a manual open fallback alongside the copy button", async () => { expect(host.textContent).toContain("Didn't open?"); }); +// The block is shared by three surfaces, so its styles must not read as owned +// by any one of them. Without this the "ownership" claim is unenforceable. +test("carries component-scoped class names, not a host surface's prefix", async () => { + await render(URL_A); + + expect(host.querySelector(".login-url-block")).toBeTruthy(); + expect(host.querySelector(".login-url-block-text")?.textContent).toBe(URL_A); + expect(host.querySelector(".login-url-block-actions")).toBeTruthy(); + expect(host.querySelector(".login-url-block-open")).toBeTruthy(); + expect(host.innerHTML).not.toContain("pwi-"); +}); + test("renders nothing without a URL", async () => { await render(""); - expect(host.querySelector(".pwi-auth-url-wrap")).toBeNull(); + expect(host.querySelector(".login-url-block")).toBeNull(); expect(host.textContent).not.toContain("Copy link"); }); diff --git a/gui/tests/provider-auth-login-copy-link.test.tsx b/gui/tests/provider-auth-login-copy-link.test.tsx index b7135059fe..8b6fd231ab 100644 --- a/gui/tests/provider-auth-login-copy-link.test.tsx +++ b/gui/tests/provider-auth-login-copy-link.test.tsx @@ -101,7 +101,7 @@ async function mountPanel(hint: LoginHint) { /** Located structurally: its label changes to "Copied" after the first click. */ function copyLinkButton(): HTMLButtonElement { - const button = host.querySelector(".pwi-auth-url-actions button"); + const button = host.querySelector(".login-url-block-actions button"); expect(button).toBeTruthy(); return button as HTMLButtonElement; } From b57621679d13f75e31884687e45f7dbc6ab00486 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Mon, 27 Jul 2026 18:00:33 +0900 Subject: [PATCH 11/13] docs(devlog): plan the two P2 fixes from the PR 544 review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 리뷰어가 useCopyFeedback 도입으로 새로 생긴 결함 둘을 짚었다. 기기 코드 복사만 스코프를 안 쓴다. 훅은 피드백이 자기 스코프를 들고 다녀 스코프가 바뀌면 idle로 읽히게 만들었는데, 세 소비처 중 여기만 undefined를 넘긴다. 취소 후 재로그인하면 loginInfo만 교체되고 패널은 마운트된 채라 새 코드 위에 이전 코드의 복사됨이 남는다 — LoginUrlBlock이 이미 막아둔 것과 같은 거짓 성공이다. 겹친 복사도 경합한다. copyTextToClipboard는 async라 권한 프롬프트로 지연된 첫 시도가 둘째보다 늦게 resolve될 수 있고, 그때 무조건 상태와 타이머를 덮어쓴다. clearTimer는 순서 문제를 못 막는다. 요청 세대 카운터로 스테일 완료를 버린다 — 이 저장소가 OAuth 폴링에서 이미 쓰는 방식이다. --- .../070_review_p2_fixes.md | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 devlog/_plan/260727_login_url_copy_parity/070_review_p2_fixes.md diff --git a/devlog/_plan/260727_login_url_copy_parity/070_review_p2_fixes.md b/devlog/_plan/260727_login_url_copy_parity/070_review_p2_fixes.md new file mode 100644 index 0000000000..f451523d7c --- /dev/null +++ b/devlog/_plan/260727_login_url_copy_parity/070_review_p2_fixes.md @@ -0,0 +1,114 @@ +# 070 — PR #544 Codex 리뷰 P2 2건 (wp1) + +`chatgpt-codex-connector`가 `171885ec29`에 남긴 인라인 지적 둘. 둘 다 +`useCopyFeedback` 도입으로 **새로 생긴** 결함이라 머지 전에 닫는다. + +## P2-1 — 기기 코드 복사가 스코프를 안 쓴다 + +`gui/src/components/provider-workspace/ProviderAuthPanel.tsx` + +```tsx +const deviceCodeCopy = useCopyFeedback(); // K = void +const deviceCodeOutcome = deviceCodeCopy.outcomeFor(undefined); +onClick={() => deviceCodeCopy.copy(hintForThis.deviceCode ?? "", undefined)} +``` + +훅은 "피드백이 자기 스코프를 들고 다녀 스코프가 바뀌면 idle로 읽힌다"는 +계약으로 만들었는데, 세 소비처 중 여기만 그 계약을 쓰지 않는다. URL은 +`url`을, doctor는 `account.id`를 스코프로 넘긴다. 기기 코드만 `undefined`다. + +### 재현 + +1. 기기 코드 A가 뜬 상태에서 복사 → 라벨이 "코드 복사됨". +2. 2.5초 안에 취소를 누른다. `cancelLoginOAuth`가 + `setLoginInfo(current => current?.provider === provider ? null : current)` + 로 힌트만 비운다(`use-providers-oauth.ts:53`). **패널은 언마운트되지 않는다** — + `busy && hintForThis` 블록만 사라진다. +3. 다시 로그인하면 `loginOAuth`가 새 코드 B로 `setLoginInfo`한다(`:84`). + 같은 컴포넌트 인스턴스, 같은 `undefined` 스코프. +4. 코드 B 위에 A의 "복사됨"이 남는다. 클립보드에는 A가 들어 있다. + +`Providers.tsx:210`의 `key={item.name}`은 프로바이더가 바뀔 때만 리마운트하므로 +같은 프로바이더 재로그인에서는 보호가 없다. 이건 `LoginUrlBlock`이 이미 +막아둔 것과 **동일한 거짓 성공**이다. + +### 수정 + +```tsx +const deviceCodeCopy = useCopyFeedback(); +const deviceCode = hintForThis?.deviceCode ?? ""; +const deviceCodeOutcome = deviceCodeCopy.outcomeFor(deviceCode); +onClick={() => deviceCodeCopy.copy(deviceCode, deviceCode)} +``` + +복사 대상 문자열이 곧 스코프다 — URL 복사와 같은 형태(`copy(url, url)`). + +## P2-2 — 겹친 복사에서 오래된 완료가 최신 결과를 덮어쓴다 + +`gui/src/components/use-copy-feedback.ts:38-47` + +```ts +void copyTextToClipboard(text).then((ok) => { + clearTimer(); + setFeedback({ scope, outcome: ok ? "copied" : "unavailable" }); + timerRef.current = setTimeout(...); +}); +``` + +`copyTextToClipboard`는 async다. `navigator.clipboard.writeText`가 권한 +프롬프트나 포커스 대기로 지연되면 첫 시도가 둘째보다 늦게 resolve될 수 있다. +그때 첫 시도의 `.then`이 무조건 상태와 타이머를 덮어쓴다. + +- 스코프가 다르면(예: 코드 A→B) 현재 버튼이 남의 스코프 피드백을 받아 idle이 된다. +- 스코프가 같으면 오래된 결과를 최신인 양 보고한다(A 실패→B 성공이면 "사용 불가"). +- 타이머도 늦은 쪽이 다시 걸어 피드백 수명이 어긋난다. + +`clearTimer`는 이 경합을 못 막는다. 순서 문제이지 타이머 문제가 아니다. + +### 수정 + +요청 세대 카운터를 둔다. + +```ts +const generationRef = useRef(0); + +const copy = useCallback((text: string, scope: Scope) => { + const generation = ++generationRef.current; + void copyTextToClipboard(text).then((ok) => { + if (generationRef.current !== generation) return; // 스테일 완료는 버린다 + clearTimer(); + setFeedback({ scope, outcome: ok ? "copied" : "unavailable" }); + timerRef.current = setTimeout(() => { ... }, FEEDBACK_MS); + }); +}, [clearTimer]); +``` + +만료 타이머도 자기 세대를 확인하게 해, 늦게 도착한 클릭이 앞 타이머의 +만료로 지워지지 않도록 한다. 이 저장소의 다른 경합 가드와 같은 방식이다 +(`use-providers-oauth.ts`의 `oauthLoginGenerationRef`, +`use-add-codex-account-oauth.ts`의 `pollSession`). + +## 회귀 테스트 + +### `gui/tests/use-copy-feedback-race.test.tsx` (신규) + +클립보드 `writeText`를 테스트가 붙잡았다 놓는 스텁으로 순서를 고정한다. + +1. **늦게 끝난 오래된 시도는 무시된다.** A를 클릭(보류) → B를 클릭(즉시 성공) + → A를 성공으로 해제. 라벨은 B의 결과를 유지한다. +2. **결과가 갈려도 마찬가지.** A는 실패, B는 성공으로 두고 A를 나중에 해제해도 + "복사됨"이 남는다. +3. **정상 순서는 그대로 동작한다.** 겹치지 않으면 마지막 클릭 결과가 뜬다. + +### `gui/tests/provider-auth-device-code-copy.test.tsx` (기존에 추가) + +4. **기기 코드가 바뀌면 라벨이 초기화된다.** 코드 A 복사 → 같은 패널에 + 코드 B로 리렌더 → 라벨이 `prov.copyCode`로 돌아온다. + +## 검증 + +- `bun run typecheck` exit 0, `cd gui && bun x tsc -b` exit 0 +- `cd gui && bun run lint` / `lint:i18n` exit 0 +- `cd gui && bun test tests` 전건 통과 +- `bun run test` (루트) 신규 실패 0, `bun run privacy:scan` 통과 +- 스코프를 `undefined`로 되돌리면 테스트 4가, 세대 가드를 지우면 1·2가 실패한다 From f459da1b3c1239b59defc44048c86a95a3d5bd73 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Mon, 27 Jul 2026 18:07:17 +0900 Subject: [PATCH 12/13] fix(gui): scope device-code copies and discard stale clipboard results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 리뷰가 useCopyFeedback 도입으로 새로 생긴 결함 둘을 짚었다. 둘 다 맞다. 기기 코드 복사만 스코프를 안 썼다. 훅은 피드백이 자기 스코프를 들고 다녀 스코프가 바뀌면 idle로 읽히게 만든 건데, 세 소비처 중 여기만 undefined를 넘겼다. 취소 후 재로그인하면 loginInfo만 교체되고 패널은 마운트된 채라 새 코드 위에 이전 코드의 '복사됨'이 남는다. 클립보드에는 옛 코드가 든 채로. LoginUrlBlock이 이미 막아둔 것과 같은 거짓 성공이고, 내가 만든 계약을 내 소비처에서 안 지킨 누락이다. 복사 대상 문자열을 그대로 스코프로 넘긴다 — URL 복사와 같은 형태다. 겹친 복사도 경합했다. writeText는 권한 프롬프트로 지연될 수 있어 첫 시도가 둘째보다 늦게 resolve될 수 있고, 그때 오래된 완료가 무조건 상태와 타이머를 덮어썼다. 스코프가 다르면 현재 버튼이 idle이 되고, 같으면 틀린 결과를 보고한다. clearTimer로는 못 막는다 — 순서 문제이지 타이머 문제가 아니다. 요청 세대를 세고 현재가 아닌 완료를 버린다. 만료 타이머도 자기 세대를 확인해 늦게 온 클릭의 피드백을 앞 타이머가 지우지 못하게 했다. 이 저장소가 OAuth 폴링에서 이미 쓰는 가드 형태다. 검증: gui 329 pass 0 fail, 루트 5022 pass 0 fail, typecheck/tsc -b/lint/ lint:i18n/privacy 통과. 세대 가드를 지우면 3건, 기기 코드 스코프를 지우면 1건 실패한다. --- .../provider-workspace/ProviderAuthPanel.tsx | 7 +- gui/src/components/use-copy-feedback.ts | 8 + .../provider-auth-device-code-copy.test.tsx | 15 ++ gui/tests/use-copy-feedback-race.test.tsx | 156 ++++++++++++++++++ 4 files changed, 183 insertions(+), 3 deletions(-) create mode 100644 gui/tests/use-copy-feedback-race.test.tsx diff --git a/gui/src/components/provider-workspace/ProviderAuthPanel.tsx b/gui/src/components/provider-workspace/ProviderAuthPanel.tsx index c1a5899710..da6a9e8103 100644 --- a/gui/src/components/provider-workspace/ProviderAuthPanel.tsx +++ b/gui/src/components/provider-workspace/ProviderAuthPanel.tsx @@ -49,7 +49,7 @@ export default function ProviderAuthPanel({ const [addingKey, setAddingKey] = useState(false); const [newKey, setNewKey] = useState(""); const [keyBusy, setKeyBusy] = useState(false); - const deviceCodeCopy = useCopyFeedback(); + const deviceCodeCopy = useCopyFeedback(); const doctorCopy = useCopyFeedback(); const surface = providerAuthSurface({ ...item, hasApiKey: item.hasApiKey || keys.length > 0 }); @@ -75,7 +75,8 @@ export default function ProviderAuthPanel({ if (!surface || !authHandlers) return null; const hintForThis = loginHint?.provider === item.name ? loginHint : null; - const deviceCodeOutcome = deviceCodeCopy.outcomeFor(undefined); + const deviceCode = hintForThis?.deviceCode ?? ""; + const deviceCodeOutcome = deviceCodeCopy.outcomeFor(deviceCode); const deviceCodeCopyLabel = deviceCodeOutcome === "copied" ? t("prov.codeCopied") : deviceCodeOutcome === "unavailable" @@ -136,7 +137,7 @@ export default function ProviderAuthPanel({ {t("prov.deviceCode")} {hintForThis.deviceCode}
    diff --git a/gui/src/components/use-copy-feedback.ts b/gui/src/components/use-copy-feedback.ts index a2fd8ec027..ab5bf53dda 100644 --- a/gui/src/components/use-copy-feedback.ts +++ b/gui/src/components/use-copy-feedback.ts @@ -20,6 +20,7 @@ export function useCopyFeedback(): { } { const [feedback, setFeedback] = useState<{ scope: Scope; outcome: CopyOutcome } | null>(null); const timerRef = useRef | null>(null); + const generationRef = useRef(0); const clearTimer = useCallback(() => { if (timerRef.current) { @@ -36,11 +37,18 @@ export function useCopyFeedback(): { ); const copy = useCallback((text: string, scope: Scope) => { + // Writes settle out of order: a permission prompt can delay the first + // attempt past a second one. Without a generation the older completion + // overwrites the newer click's result — and its timer expires the wrong + // feedback. Same guard shape the OAuth polling paths already use. + const generation = ++generationRef.current; void copyTextToClipboard(text).then((ok) => { + if (generationRef.current !== generation) return; clearTimer(); setFeedback({ scope, outcome: ok ? "copied" : "unavailable" }); timerRef.current = setTimeout(() => { timerRef.current = null; + if (generationRef.current !== generation) return; setFeedback(null); }, FEEDBACK_MS); }); diff --git a/gui/tests/provider-auth-device-code-copy.test.tsx b/gui/tests/provider-auth-device-code-copy.test.tsx index 3a1334e6be..1f7c0d7864 100644 --- a/gui/tests/provider-auth-device-code-copy.test.tsx +++ b/gui/tests/provider-auth-device-code-copy.test.tsx @@ -154,6 +154,21 @@ test("reports an unusable clipboard instead of dying silently", async () => { expect(host.textContent).toContain(DEVICE_CODE); }); +test("a new device code does not inherit the previous code's feedback", async () => { + await mountPanel({ provider: "claude", deviceCode: DEVICE_CODE }); + await clickCopy(); + expect(host.textContent).toContain("Code copied"); + + // Cancel + restart replaces loginInfo while the panel stays mounted, so the + // next code would otherwise show a copy it never received. + await mountPanel({ provider: "claude", deviceCode: "QRST-UVWX" }); + + expect(host.textContent).toContain("QRST-UVWX"); + expect(host.textContent).not.toContain(DEVICE_CODE); + expect(host.textContent).not.toContain("Code copied"); + expect(host.textContent).toContain("Copy code"); +}); + test("keeps the latest feedback for its full window across repeated copies", async () => { await mountPanel({ provider: "claude", deviceCode: DEVICE_CODE }); diff --git a/gui/tests/use-copy-feedback-race.test.tsx b/gui/tests/use-copy-feedback-race.test.tsx new file mode 100644 index 0000000000..b207f79e99 --- /dev/null +++ b/gui/tests/use-copy-feedback-race.test.tsx @@ -0,0 +1,156 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import { LanguageProvider } from "../src/i18n/provider"; +import { useCopyFeedback } from "../src/components/use-copy-feedback"; + +/** + * Clipboard writes settle out of order — a permission prompt can hold the first + * attempt past a second one. Without a request generation the older completion + * overwrites the newer click's result and expires the wrong feedback. + */ + +const globals = ["document", "window", "navigator", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previous: Record<(typeof globals)[number], unknown>; +let win: Window; +let host: HTMLElement; +let root: Root | null = null; + +/** Each write parks until the test releases it, so ordering is explicit. */ +let pending: Array<{ text: string; settle: (ok: boolean) => void }> = []; + +function installDeferredClipboard() { + pending = []; + Object.defineProperty(win.navigator, "clipboard", { + configurable: true, + value: { + writeText: (text: string) => new Promise((resolve, reject) => { + pending.push({ text, settle: (ok) => { if (ok) resolve(); else reject(new Error("denied")); } }); + }), + }, + }); +} + +function Probe() { + const { outcomeFor, copy } = useCopyFeedback(); + return ( +
    + {outcomeFor("a") ?? "idle"} + {outcomeFor("b") ?? "idle"} +
    + ); +} + +beforeEach(() => { + previous = Object.fromEntries(globals.map((k) => [k, Reflect.get(globalThis, k)])) as typeof previous; + win = new Window({ url: "http://localhost/" }); + Object.defineProperty(win.navigator, "language", { configurable: true, value: "en-US" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: win.document }, + window: { configurable: true, value: win }, + navigator: { configurable: true, value: win.navigator }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + installDeferredClipboard(); + // No legacy path: a rejected write must not silently succeed through execCommand. + Object.defineProperty(win.document, "execCommand", { configurable: true, value: undefined }); + host = win.document.createElement("div") as unknown as HTMLElement; + win.document.body.appendChild(host as never); +}); + +afterEach(async () => { + if (root) { + const current = root; + await act(async () => { current.unmount(); }); + root = null; + } + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previous[key] }); + } + await win.happyDOM?.close?.(); +}); + +async function mount() { + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root = createRoot(host); + root.render(); + }); +} + +function text(id: string): string { + return host.querySelector(`[data-testid="${id}"]`)?.textContent ?? ""; +} + +async function click(id: string) { + await act(async () => { + host.querySelector(`[data-testid="${id}"]`) + ?.dispatchEvent(new win.MouseEvent("click", { bubbles: true })); + await new Promise((r) => setTimeout(r, 0)); + }); +} + +async function settle(index: number, ok: boolean) { + await act(async () => { + pending[index]?.settle(ok); + await new Promise((r) => setTimeout(r, 0)); + }); +} + +test("a stale completion never overwrites the newer click", async () => { + await mount(); + + await click("copy-a"); + await click("copy-b"); + expect(pending.map((p) => p.text)).toEqual(["a", "b"]); + + await settle(1, true); // the newer click lands first + expect(text("b")).toBe("copied"); + + await settle(0, true); // the older one arrives late + expect(text("b")).toBe("copied"); + expect(text("a")).toBe("idle"); +}); + +test("a late failure does not downgrade a newer success", async () => { + await mount(); + + await click("copy-a"); + await click("copy-b"); + + await settle(1, true); + await settle(0, false); // A was denied, but B already succeeded + + expect(text("b")).toBe("copied"); + expect(text("a")).toBe("idle"); +}); + +test("a stale timer cannot expire the newer feedback", async () => { + await mount(); + + await click("copy-a"); + await click("copy-b"); + + await settle(1, true); + await settle(0, true); + + // Past the stale attempt's would-be expiry, inside the current one's window. + await act(async () => { await new Promise((r) => setTimeout(r, 2400)); }); + expect(text("b")).toBe("copied"); +}); + +test("sequential copies still report the latest result", async () => { + await mount(); + + await click("copy-a"); + await settle(0, true); + expect(text("a")).toBe("copied"); + + await click("copy-b"); + await settle(1, false); + expect(text("b")).toBe("unavailable"); + expect(text("a")).toBe("idle"); +}); From 80772447c9d1ed16d51488ce12c4208bae26587c Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Mon, 27 Jul 2026 18:21:37 +0900 Subject: [PATCH 13/13] fix(gui): announce doctor-copy state and stop racing a timer in tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit이 남은 셋을 짚었다. doctor 복사만 aria-live가 없었다. 이번 라운드가 URL 복사와 기기 코드 복사에 라이브 리전을 붙여놓고 세 번째 표면은 빠뜨렸다. 같은 훅으로 옮겨놓고 고지 규약만 빠뜨린 셈이라 스크린리더에는 상태 변화가 들리지 않는다. 세 소비처 전부 붙인다. 계정 추가 모달 테스트가 고정 40ms 지연에 기대고 있었다. 느린 워커에서는 startOAuth가 authUrl을 커밋하기 전에 단언이 돌 수 있다. 로그인 응답을 테스트가 붙잡았다 act 안에서 놓는 방식으로 바꿨다 — 070 문서가 스스로 '응답을 명시적으로 기다리라'고 적어둔 것을 정작 이 파일에서 안 지키고 있었다. 실행 시간도 2.8초에서 0.16초로 줄었다. 계획 문서의 목록 번호는 markdownlint MD029에 걸려 1부터 다시 센다. 검증: gui 329 pass 0 fail, typecheck/tsc -b/lint/lint:i18n/privacy 통과. --- .../070_review_p2_fixes.md | 4 ++-- .../components/codex-account-pool-cards.tsx | 2 +- .../codex-account-pool-main-card.tsx | 2 +- .../provider-workspace/ProviderAuthPanel.tsx | 2 +- .../add-codex-account-login-url.test.tsx | 20 ++++++++++++++++--- 5 files changed, 22 insertions(+), 8 deletions(-) diff --git a/devlog/_plan/260727_login_url_copy_parity/070_review_p2_fixes.md b/devlog/_plan/260727_login_url_copy_parity/070_review_p2_fixes.md index f451523d7c..36ea7c19cf 100644 --- a/devlog/_plan/260727_login_url_copy_parity/070_review_p2_fixes.md +++ b/devlog/_plan/260727_login_url_copy_parity/070_review_p2_fixes.md @@ -102,7 +102,7 @@ const copy = useCallback((text: string, scope: Scope) => { ### `gui/tests/provider-auth-device-code-copy.test.tsx` (기존에 추가) -4. **기기 코드가 바뀌면 라벨이 초기화된다.** 코드 A 복사 → 같은 패널에 +1. **기기 코드가 바뀌면 라벨이 초기화된다.** 코드 A 복사 → 같은 패널에 코드 B로 리렌더 → 라벨이 `prov.copyCode`로 돌아온다. ## 검증 @@ -111,4 +111,4 @@ const copy = useCallback((text: string, scope: Scope) => { - `cd gui && bun run lint` / `lint:i18n` exit 0 - `cd gui && bun test tests` 전건 통과 - `bun run test` (루트) 신규 실패 0, `bun run privacy:scan` 통과 -- 스코프를 `undefined`로 되돌리면 테스트 4가, 세대 가드를 지우면 1·2가 실패한다 +- 스코프를 `undefined`로 되돌리면 기기 코드 테스트가, 세대 가드를 지우면 경합 테스트가 실패한다 diff --git a/gui/src/components/codex-account-pool-cards.tsx b/gui/src/components/codex-account-pool-cards.tsx index 13929768df..478b0e8a21 100644 --- a/gui/src/components/codex-account-pool-cards.tsx +++ b/gui/src/components/codex-account-pool-cards.tsx @@ -83,7 +83,7 @@ export function CodexAccountPoolCards({ )} {onCopyDoctor && oauthHealthShowsDoctor(healthStatus) && ( )} )} {t("codexAuth.appLogin")} diff --git a/gui/src/components/provider-workspace/ProviderAuthPanel.tsx b/gui/src/components/provider-workspace/ProviderAuthPanel.tsx index da6a9e8103..efa61b33e0 100644 --- a/gui/src/components/provider-workspace/ProviderAuthPanel.tsx +++ b/gui/src/components/provider-workspace/ProviderAuthPanel.tsx @@ -217,7 +217,7 @@ export default function ProviderAuthPanel({ )} {showDoctor && ( )}