Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions devlog/_plan/260727_login_url_copy_parity/000_inventory.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# 000 — 로그인 URL 복사 어포던스 인벤토리 (조사)

세 개의 OAuth 로그인 대기 표면이 서로 다른 수준의 어포던스를 들고 있다.
5919779d가 둘을 고쳤고 하나는 그대로 남았다. 아래는 조사 시점(dev @ f327db1e)의
실측이다.

## 표면 A — Provider Workspace 설정 패널

`gui/src/components/provider-workspace/ProviderAuthPanel.tsx:150-172`

- URL 전문: `<code className="pwi-auth-url">{hintForThis.url}</code>` — 있음
- 복사 버튼: 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 정합, 전체 검증.
136 changes: 136 additions & 0 deletions devlog/_plan/260727_login_url_copy_parity/010_shared_block.md
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof setTimeout> | 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
<div className={`pwi-auth-url-wrap${className ? ` ${className}` : ""}`}>
<code className="pwi-auth-url">{url}</code>
<div className="pwi-auth-url-actions">
<button type="button" className="btn btn-ghost btn-sm" onClick={copy}>
<IconLink style={{ width: 13, height: 13 }} aria-hidden="true" />
<span aria-live="polite">{label}</span>
</button>
<a href={url} target="_blank" rel="noreferrer" className="pwi-auth-open-link">
<IconExternal style={{ width: 13, height: 13 }} aria-hidden="true" /> {t("prov.didntOpen")}
</a>
</div>
</div>
```

클래스명은 이번 단계에서 바꾸지 않는다. `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` 전건 통과
- 컴포넌트를 되돌리면 신규 테스트가 실패한다(가드 실효 확인)
106 changes: 106 additions & 0 deletions devlog/_plan/260727_login_url_copy_parity/020_codex_modal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# 020 — Codex 계정 추가/재인증 모달 채택 (wp2)

이 루프의 본체다. 사용자가 보고한 증상("계정 추가할 때 복사 버튼 관련")이
바로 이 표면이다.

## 변경 1 — `gui/src/components/add-codex-account-waiting-step.tsx`

현재:

```tsx
<button ... onClick={onCopyLoginLink} disabled={!authUrl} ...>
<IconLink width={14} /> {copied ? t("codexAuth.loginLinkCopied") : t("codexAuth.copyLoginLink")}
</button>
```

변경 후: 위 버튼을 `<LoginUrlBlock url={authUrl} />` 하나로 교체한다.

**빈 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` 제거(더 이상 이 파일이 아이콘을 직접 쓰지 않는다).
- 배치: `<p className="modal-desc">` 바로 아래, 수동 코드 입력 블록 위.
대기 화면의 읽기 순서는 "기다리는 중이다 → 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` 제거.
- `<AddCodexAccountWaitingStep>`에서 `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)
Loading
Loading