<!-- task=m-control-plane-client/02+01_client_lifecycle_ui plan=1 tag=REVIEW_CLIENTOPS -->

# Client Lifecycle UI Follow-up Plan

## 이 파일을 읽는 구현 에이전트에게

이 계획은 직전 코드리뷰 `code_review_cloud_G07_0.log`의 Required 이슈만 수습한다. 구현 후 `CODE_REVIEW-cloud-G07.md`의 구현 에이전트 소유 섹션을 실제 구현 내용, 검증 명령, 출력 요약으로 채운다. 최종 판정, log rename, `complete.log` 작성은 code-review 스킬 책임이다. 사용자만 결정할 수 있는 blocker가 있으면 review stub의 `사용자 리뷰 요청` 섹션에 정확한 결정, 증거, 재개 조건을 채우고 멈춘다.

## Roadmap Targets

- Milestone: `agent-roadmap/phase/control-plane-portal-ops/milestones/control-plane-client.md`
- Task ids:
  - `client-status`: Client는 Edge/Node 설정 요약, registry, 연결 상태, 로그/이벤트를 lifecycle 중심으로 얇게 보여줄 수 있다.
  - `client-via-cp`: Client는 Control Plane을 통해 Edge/Node 운영 lifecycle 상태를 조회한다.
- Completion mode: check-on-pass

## 배경

1차 구현은 Control Plane HTTP status client와 Edges/Nodes/Execution panels를 추가했고 `cd apps/client && flutter test`도 통과했다. 하지만 `/edges` refresh 후 기존 selected edge가 목록에서 사라지는 lifecycle 전환을 처리하지 않아 stale id로 상세 API를 재호출하거나 `DropdownButton` assertion이 날 수 있다.

## 범위 결정 근거

- API schema, Control Plane server, iop_console package slot 계약은 변경하지 않는다.
- 후속 범위는 selected edge 정규화, stale 상세 상태 정리, 이 전환을 검증하는 widget test 추가로 제한한다.
- URL encoding이나 Edge id character policy 변경은 이 follow-up의 직접 Required 이슈가 아니므로 건드리지 않는다.

## 구현 체크리스트

- [ ] `/edges` refresh 결과 기준으로 `_selectedEdgeId`를 현재 edge 목록 안의 id 또는 `null`로 정규화하고, stale status/events/loading/error cache가 새 selection을 깨지 않게 정리한다.
- [ ] `NodesPanel`과 `ExecutionLogsPanel`에 items에 없는 `DropdownButton.value`가 전달되지 않도록 app state 또는 panel fallback을 보강한다.
- [ ] `apps/client/test/widget_test.dart`에 refresh 이후 선택 edge가 사라지는 fake repository scenario를 추가하고 Edges/Nodes/Execution 탭이 assertion 없이 새 edge 또는 empty state를 표시하는지 검증한다.
- [ ] `cd apps/client && flutter test`를 실행하고 실제 stdout/stderr 요약을 `CODE_REVIEW-cloud-G07.md`에 기록한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

### [REVIEW_CLIENTOPS-1] Normalize Selected Edge After Registry Refresh

문제: [main.dart](/config/workspace/iop/apps/client/lib/main.dart:204)는 새 edge 목록을 저장하면서 기존 `_selectedEdgeId`가 아직 유효한지 확인하지 않는다. 그 결과 [main.dart](/config/workspace/iop/apps/client/lib/main.dart:214)가 사라진 edge id로 상세 API를 다시 요청할 수 있다.

해결 방법: `_fetchEdges`에서 새 목록을 받은 뒤 다음 규칙으로 selection을 계산한다.

- 기존 `_selectedEdgeId`가 새 목록에 있으면 유지한다.
- 없고 새 목록이 비어 있지 않으면 첫 edge id로 교체한다.
- 새 목록이 비어 있으면 `null`로 둔다.
- 상세 fetch는 계산된 id가 있을 때만 호출한다.
- 필요하면 사라진 edge id의 loading/error/detail cache를 정리해 stale UI가 다음 탭에 남지 않게 한다.

수정 파일 및 체크리스트:

- [ ] `apps/client/lib/main.dart`
- [ ] `apps/client/test/widget_test.dart`

테스트 작성: 작성한다. fake repository가 첫 fetch에서는 `edge-a`, refresh 이후에는 `edge-b`만 반환하게 만들고 refresh 후 Nodes 또는 Execution 탭 진입이 assertion 없이 동작하는지 확인한다.

중간 검증: `cd apps/client && flutter test test/widget_test.dart`.

### [REVIEW_CLIENTOPS-2] Guard Dropdown Selection In Panels

문제: [control_plane_status_widgets.dart](/config/workspace/iop/apps/client/lib/control_plane_status_widgets.dart:327)와 [control_plane_status_widgets.dart](/config/workspace/iop/apps/client/lib/control_plane_status_widgets.dart:596)는 `selectedEdgeId ?? edges.first.edgeId`만 사용한다. `selectedEdgeId`가 `edges`에 없으면 `DropdownButton` value/items 불일치 assertion이 발생할 수 있다.

해결 방법: panel 내부에서도 `edges.any((e) => e.edgeId == selectedEdgeId)`를 기준으로 safe current id를 계산하거나, app state가 보장하는 helper를 둔다. 중복 helper를 추가한다면 app-owned widget 파일 안에 작게 유지한다.

수정 파일 및 체크리스트:

- [ ] `apps/client/lib/control_plane_status_widgets.dart`
- [ ] `apps/client/test/widget_test.dart`

테스트 작성: 작성한다. panel 단위 또는 app-level widget test로 stale selected id가 있어도 Dropdown assertion이 나지 않는 경로를 검증한다.

중간 검증: `cd apps/client && flutter test test/widget_test.dart`.

## 최종 검증

```bash
cd apps/client && flutter test
```

예상 결과: Flutter tests가 모두 통과하고, refresh 이후 선택 edge가 사라지는 lifecycle scenario가 새 테스트로 검증된다.
