chore: update plan automation and chat status UI
This commit is contained in:
@@ -1,4 +1,7 @@
|
||||
NODE_VERSION=22.22.2
|
||||
CAPTURE_BASE_URL=https://test.sm-home.cloud/
|
||||
CAPTURE_REGISTERED_ACCESS_TOKEN=usr_7f3a9c2d8e1b4a6f
|
||||
VITE_ALLOWED_REGISTRATION_TOKEN=usr_7f3a9c2d8e1b4a6f
|
||||
PHOTOPRISM_PORT=2342
|
||||
PHOTOPRISM_ORIGINALS_SOURCE=/mnt/usb/photos
|
||||
PHOTOPRISM_SITE_URL=https://photo.sm-home.cloud/
|
||||
|
||||
29
AGENTS.md
29
AGENTS.md
@@ -8,13 +8,16 @@
|
||||
|
||||
### Codex / AI 기본 규칙
|
||||
|
||||
* 사용자가 구현, 수정, 실행, 설정 변경, 문서 수정, 채팅 응답 개선, 작업 메모 반영을 요청하면 **현재 로컬 `main`에서 바로 작업**한다
|
||||
* 사용자가 구현, 수정, 실행, 설정 변경, 문서 수정, 채팅 응답 개선을 요청하면 **현재 로컬 `main`에서 바로 작업**한다
|
||||
* 브라우저 확인, 화면 검증, 접속 테스트, 기본 작업 도메인은 **`https://test.sm-home.cloud/` 하나만 기준으로 사용**한다
|
||||
* 브라우저 스크린샷/캡처 검증은 기본적으로 **루트 `.env`의 `CAPTURE_BASE_URL=https://test.sm-home.cloud/`와 등록 토큰(`CAPTURE_REGISTERED_ACCESS_TOKEN`, `VITE_ALLOWED_REGISTRATION_TOKEN`)을 사용해 토큰 등록 상태에서 진행**한다
|
||||
* 별도로 운영 중인 `4173` 포트의 `ai-code-app-preview` 컨테이너는 **채팅 전용 테스트 서버가 아니라 현재 프로젝트 루트 기준 화면/기능 확인용 테스트 컨테이너**로 해석한다
|
||||
* `test.sm-home.cloud` nginx 프록시는 **화면 `/`만 `5174` 앱 테스트 서버로 보내고, `/api/`와 `/ws/chat`은 항상 `127.0.0.1:3100` work-server로 유지**한다
|
||||
* `test.sm-home.cloud`의 `/api`, `/ws/chat`, `/.codex_chat`, `/public/.codex_chat` 프록시 대상은 사용자가 명시적으로 요청하지 않는 한 임의로 변경하지 않는다
|
||||
* 별도 지시가 없으면 `sm-home.cloud`, `rel.sm-home.cloud` 같은 다른 외부 도메인은 작업 기준으로 삼지 않는다
|
||||
* `채팅`, `작업 메모`, `작업메모`, `메모 반영`, `문서 반영` 요청은 기본적으로 **브랜치 생성 없이 main 직접 수정**으로 해석한다
|
||||
* `채팅`, `메모 반영`, `문서 반영` 요청은 기본적으로 **브랜치 생성 없이 main 직접 수정**으로 해석한다
|
||||
* `자동화 작업메모`, `자동화 메모`, 자동화 접수된 작업메모는 **항상 신규 `feature/*` 브랜치를 생성해 작업**하고, 이후 `release` 반영과 `main` 일괄반영까지 진행한다
|
||||
* 자동화 작업메모의 `main` 일괄반영이 끝나면 **프로젝트 루트에서 최신 `main`을 `pull --ff-only`로 동기화**한다
|
||||
* `git remote`, `fetch`, `pull`, `push`, `branch`, `switch`, `checkout`, `merge`, `rebase`, `reset`, `stash`, `tag`, `commit` 같은 Git 관리 작업은 기본적으로 수행하지 않는다
|
||||
* 사용자가 Git 작업을 **명시적으로 요청한 경우에만** 필요한 명령을 최소 범위로 수행한다
|
||||
* 원격 저장소 연결 복구, 브랜치 전략 복구, release/main 동기화, 자동 merge 같은 작업을 자동으로 시도하지 않는다
|
||||
@@ -27,7 +30,7 @@
|
||||
* 사용자가 `git`, `브랜치`, `원격`, `push`, `pull`, `merge`, `commit`를 명시적으로 언급할 때만 Git 작업으로 해석한다
|
||||
* 사용자가 `Plan 등록`, `Plan 게시판 등록`, `게시판 등록`, `계획 등록`이라고 말하면 **Plan 게시판 항목 생성** 의미로 해석한다
|
||||
* 사용자가 `자동화 등록`, `자동화 접수`, `자동화 실행`, `자동화 돌려줘`라고 말하면 **자동화 API 등록 또는 실제 자동화 수행** 의미로 해석한다
|
||||
* 사용자가 `자동화 메모`라고만 말하면 게시판 메모인지 자동화 접수 메모인지 문맥을 먼저 확인한다
|
||||
* 사용자가 `자동화 메모`라고만 말하면 기본적으로 **자동화 접수 작업메모**로 해석하고, 신규 `feature/*` 브랜치 생성부터 `release` 반영, `main` 일괄반영, 프로젝트 루트 `pull`까지 포함한 흐름을 적용한다
|
||||
* 사용자가 `Plan 게시판에 등록만`, `자동화 없이`, `구현하지 말고 계획만 등록`, `게시판만`처럼 표현하면 **자동화 실행 없이 Plan 항목만 등록**한다
|
||||
* 사용자가 `자동화해줘`, `구현해줘`, `작업 진행해줘`, `실행해줘`처럼 실제 수행을 명시한 경우에만 자동화 또는 코드 작업으로 해석한다
|
||||
* 요청이 모호하면 자동화 실행을 바로 진행하지 말고, 우선 **Plan 등록**으로 안전 해석하거나 짧게 재확인한다
|
||||
@@ -45,31 +48,33 @@
|
||||
|
||||
## Codex Live / 채팅 / 작업 메모 규칙
|
||||
|
||||
* `Codex Live`, 일반 채팅, 작업 메모 반영 요청은 모두 현재 프로젝트의 로컬 `main`을 기준으로 처리한다
|
||||
* `Codex Live`, 일반 채팅 요청은 현재 프로젝트의 로컬 `main`을 기준으로 처리한다
|
||||
* 자동화 작업메모 반영 요청은 예외적으로 **신규 `feature/*` 브랜치 생성 -> `release` 반영 -> `main` 일괄반영 -> 프로젝트 루트 `pull`** 순서를 기본으로 처리한다
|
||||
* 외부 도메인 기준 동작 확인이 필요하면 기본 대상은 항상 `https://test.sm-home.cloud/`로 본다
|
||||
* `https://test.sm-home.cloud/`에서 채팅/API 문제가 보이면 먼저 프런트 코드보다 **nginx의 `/api`와 `/ws/chat` 프록시가 `3100`을 가리키는지** 확인한다
|
||||
* 채팅에서 나온 수정 요청도 별도 브랜치 생성 없이 바로 파일 수정으로 이어질 수 있다
|
||||
* 작업 메모는 기록 목적이든 실제 수정 지시든 우선 `main` 기준의 로컬 작업으로 연결한다
|
||||
* 채팅과 작업 메모는 Git flow를 강제하지 않고, 필요한 경우에만 사용자가 별도로 Git 단계를 요청한다
|
||||
* 일반 작업 메모는 기록 목적이면 메모로 유지하고, 실제 수정 지시라도 별도 자동화 접수가 아니면 `main` 기준 로컬 작업으로 연결한다
|
||||
* 자동화 작업메모는 Git flow를 기본으로 적용하며, 일반 채팅/수동 작업은 필요한 경우에만 사용자가 별도로 Git 단계를 요청한다
|
||||
* 채팅에서 파일/문서/이미지/코드 리소스를 제공할 때는 반드시 `public/.codex_chat/<chat-session-id>/resource/` 아래 세션 전용 경로를 기준으로 사용한다
|
||||
* 채팅 첨부 파일은 `public/.codex_chat/<chat-session-id>/resource/uploads/` 아래 경로를 기준으로 사용한다
|
||||
* 원본 파일 경로만 응답해도 서버가 해당 리소스를 위 세션 경로로 복사해 공개 링크로 바꿔 줄 수 있지만, 문서와 안내 문구에는 위 공개 경로 기준을 우선 명시한다
|
||||
* 토큰 등록이 필요한 화면 검증이나 모바일 스크린샷은 **비로그인 기본 화면(Docs)으로 확인하지 말고**, `.env`에 저장된 등록 토큰을 브라우저 `localStorage`에 주입한 상태로 캡처한다
|
||||
|
||||
---
|
||||
|
||||
## Plan / 자동화 메모
|
||||
|
||||
* 기존 문서에 남아 있는 `feature`, `hotfix`, `release` 흐름은 **현재 로컬 모드에서는 기본 규칙으로 사용하지 않는다**
|
||||
* Plan 게시판과 자동화 관련 기능 설명은 UI/상태 설명으로만 참고하고, 실제 Git 브랜치 운영 규칙으로 자동 적용하지 않는다
|
||||
* 사용자가 나중에 브랜치 전략 복구를 명시적으로 요청하면 그때 별도 문서 갱신 후 다시 적용한다
|
||||
* 일반 수동 작업에는 여전히 로컬 `main` 직접 수정 원칙을 유지한다
|
||||
* 다만 자동화 작업메모와 Plan 자동화에는 기존 `feature -> release -> main -> 프로젝트 루트 pull` 흐름을 기본 규칙으로 다시 적용한다
|
||||
* `hotfix/*` 흐름은 기존 예외 규칙을 유지하고, 자동화 대상이 아닌 일반 요청에는 자동 적용하지 않는다
|
||||
|
||||
---
|
||||
|
||||
## 한 줄 요약
|
||||
|
||||
👉 지금은 로컬 `main`에서 바로 수정한다
|
||||
👉 일반 채팅/수동 요청은 로컬 `main`에서 바로 수정한다
|
||||
👉 외부 확인 기본 도메인은 `https://test.sm-home.cloud/` 하나다
|
||||
👉 채팅이든 작업 메모든 기본 해석은 `main` 직접 작업이다
|
||||
👉 Git 작업은 사용자가 명시적으로 요청할 때만 최소 범위로 한다
|
||||
👉 자동화 작업메모는 `feature -> release -> main -> 프로젝트 루트 pull` 흐름을 탄다
|
||||
👉 일반 Git 작업은 사용자가 명시적으로 요청할 때만 최소 범위로 한다
|
||||
|
||||
---
|
||||
|
||||
@@ -7,7 +7,8 @@ React + Vite + TypeScript 기반의 문서/샘플 허브 애플리케이션입
|
||||
- 현재 저장소는 당분간 **로컬 전용 작업 모드**로 사용합니다.
|
||||
- Git 원격 동기화, 브랜치 운영, 자동 병합/자동화는 잠시 중지한 상태로 간주합니다.
|
||||
- Codex나 자동화 도구는 기본적으로 Git 작업 없이 **현재 프로젝트 루트의 `main` 작업본을 바로 수정**합니다.
|
||||
- `채팅`, `Codex Live`, `작업메모`, `메모 반영` 요청도 같은 기준으로 해석합니다.
|
||||
- `채팅`, `Codex Live`, 일반 `작업메모`, `메모 반영` 요청도 같은 기준으로 해석합니다.
|
||||
- 단, 자동화 접수된 작업메모는 예외적으로 **신규 `feature/*` 브랜치 생성 -> `release` 반영 -> `main` 일괄반영 -> 프로젝트 루트 `pull --ff-only`** 흐름을 사용합니다.
|
||||
- 채팅 리소스와 첨부 파일은 `public/.codex_chat/<chat-session-id>/resource/...` 기준으로 제공하며, 업로드 파일은 `public/.codex_chat/<chat-session-id>/resource/uploads/...` 아래를 사용합니다.
|
||||
|
||||
## 시작하기
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
|
||||
- 현재 저장소는 당분간 로컬 전용으로 운영합니다.
|
||||
- 문서 정리나 Codex 작업 시 Git 원격 동기화, 브랜치 운영, 자동 merge 흐름은 기본 전제로 사용하지 않습니다.
|
||||
- `Codex Live`, 일반 채팅, 작업메모 반영 요청은 **현재 프로젝트 루트의 로컬 `main` 작업본을 바로 수정**하는 방식으로 처리합니다.
|
||||
- `Codex Live`, 일반 채팅, 일반 작업메모 반영 요청은 **현재 프로젝트 루트의 로컬 `main` 작업본을 바로 수정**하는 방식으로 처리합니다.
|
||||
- 자동화 작업메모는 예외적으로 **신규 `feature/*` 브랜치 생성 -> `release` 반영 -> `main` 일괄반영 -> 프로젝트 루트 `pull --ff-only`** 흐름으로 처리합니다.
|
||||
- Git 관련 작업은 사용자가 명시적으로 요청할 때만 수행합니다.
|
||||
|
||||
## 1. 작업일지
|
||||
@@ -149,7 +150,7 @@ src/components
|
||||
- `Plan` 기능은 `src/features/planBoard`에서 관리
|
||||
- 현재 앱에는 목록/상세 보드, release 검수, 차트, 스케줄 화면이 함께 포함됨
|
||||
- 기본 상태는 `등록`, `작업중`, `작업완료`, `릴리즈완료`, `완료`
|
||||
- 자동화 진행은 `workerStatus`로 별도 관리하며 브랜치 준비, 자동 작업, release/main 반영 상태를 표현
|
||||
- 자동화 진행은 `workerStatus`로 별도 관리하며 브랜치 준비, 자동 작업, release/main 반영, 프로젝트 루트 pull 완료 상태를 포함한 흐름을 표현
|
||||
- `작업시작` 이후에는 원본 요청(`작업 ID`, 원본 메모`)을 수정하지 않고 조치 이력으로 누적 기록
|
||||
- 권한 토큰이 없으면 조회 중심으로 동작하며 민감 메모와 소스 작업 일부는 제한 또는 마스킹
|
||||
- 관련 기능 문서는 `docs/features/plan-board-review.md`, `plan-automation.md`, `plan-schedule.md`, `plan-usage.md` 참고
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
Plan 자동화 기능의 데이터 구조, API 연동 방식, release 검수 연계 방식을 정리합니다.
|
||||
|
||||
현재 운영 규칙에서 자동화 작업메모(`auto_worker`)는 항상 신규 `feature/*` 브랜치에서 시작하며, `release` 반영과 `main` 일괄반영, 프로젝트 루트 `pull --ff-only`까지 이어집니다. 반면 `Codex Live`나 일반 수동 요청은 여전히 로컬 `main` 직접 수정 기준을 유지합니다.
|
||||
|
||||
## 구현 위치
|
||||
|
||||
- 화면 진입: `src/app/main/MainContent.tsx`
|
||||
@@ -114,6 +116,8 @@ Plan 상세에서는 최신 자동 작업 결과를 탭 형태로 확인할 수
|
||||
|
||||
Codex 실행 로그에 `tokens used`가 잡히면 source work 기록과 상세 상단 상태 영역에 함께 표기합니다.
|
||||
|
||||
자동화 작업메모가 `main` 반영 단계까지 끝나면 worker는 `PLAN_MAIN_PROJECT_REPO_PATH` 기준 프로젝트 루트에서 `pull --ff-only`를 수행해 실제 작업본을 최신 `main`으로 맞춥니다.
|
||||
|
||||
## 차트 집계 방식
|
||||
|
||||
`charts.tsx`는 Plan 전체 목록을 주기적으로 다시 불러와 최근 성과를 집계합니다.
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
2. `작업시작` 또는 자동화에 따라 브랜치 준비와 작업이 진행됩니다.
|
||||
3. 작업이 완료되면 release 반영 상태와 main 반영 대기 여부가 추적됩니다.
|
||||
4. release 검수 화면에서 샘플/위젯/변경 파일을 확인하고 검수 상태를 갱신합니다.
|
||||
5. main 반영이 끝나면 최종 완료 흐름으로 정리됩니다.
|
||||
5. main 반영이 끝나면 프로젝트 루트 `pull --ff-only`까지 수행한 뒤 최종 완료 흐름으로 정리됩니다.
|
||||
|
||||
## 목록 기능
|
||||
|
||||
@@ -88,6 +88,8 @@
|
||||
- `작업취소`
|
||||
- `main 일괄 반영 요청`
|
||||
|
||||
자동화 작업메모(`auto_worker`)는 항상 신규 `feature/*` 브랜치로 시작하며, `release` 반영과 `main` 일괄 반영 뒤 프로젝트 루트 동기화까지 포함합니다.
|
||||
|
||||
세부 액션은 `/plan/items/:id/actions/:action` API로 실행됩니다.
|
||||
|
||||
## 자동 새로고침과 알림
|
||||
|
||||
@@ -57,7 +57,15 @@ npm run server-command:runner
|
||||
|
||||
소스 수정이 필요하면 **현재 실행 환경에서 실제로 연결된 `main_project` 저장소 루트의 `AGENTS.md` 규칙을 먼저 확인한 뒤**, 그 작업 트리에서 바로 수정합니다.
|
||||
|
||||
현재 운영 기준에서는 `Codex Live`, 일반 채팅, 작업메모 반영 요청 모두 **현재 프로젝트 루트의 로컬 `main` 작업본을 바로 수정**합니다. 별도 브랜치 생성이나 `release -> main` 동기화는 기본 전제로 사용하지 않으며, Git 관련 작업은 사용자가 명시적으로 요청할 때만 수행합니다.
|
||||
현재 운영 기준에서는 `Codex Live`, 일반 채팅, 일반 작업메모 반영 요청은 **현재 프로젝트 루트의 로컬 `main` 작업본을 바로 수정**합니다.
|
||||
|
||||
단, 자동화 작업메모(`auto_worker`)는 예외적으로 아래 Git 흐름을 기본 동작으로 사용합니다.
|
||||
|
||||
- 신규 `feature/*` 브랜치 생성
|
||||
- 자동 작업 수행
|
||||
- `release` 브랜치 반영
|
||||
- `main` 일괄반영
|
||||
- `PLAN_MAIN_PROJECT_REPO_PATH` 기준 프로젝트 루트에서 `pull --ff-only`
|
||||
|
||||
브라우저 기준 접속 확인, 화면 검증, 외부 도메인 테스트는 **`https://test.sm-home.cloud/`를 기본 작업 도메인으로 사용**합니다. 별도 요청이 없는 한 `sm-home.cloud`나 `rel.sm-home.cloud`는 기본 검증 대상으로 삼지 않습니다.
|
||||
|
||||
@@ -69,14 +77,15 @@ npm run server-command:runner
|
||||
|
||||
`Plan` 게시판 항목을 작업 큐처럼 읽어 자동화할 수 있습니다.
|
||||
|
||||
현재 로컬 운영 모드에서는 아래 자동 브랜치 흐름을 기본 동작으로 강제하지 않습니다. 필요 시 사용자가 별도로 요청한 경우에만 사용합니다.
|
||||
현재 운영 기준에서 자동화 작업메모는 아래 브랜치 흐름을 기본 동작으로 사용합니다.
|
||||
|
||||
- `등록` 상태: worker가 읽어서 `feature/plan-{id}-{workId}` 브랜치 생성 시도
|
||||
- `등록` 상태: worker가 읽어서 신규 `feature/plan-{id}-{workId}` 브랜치 생성
|
||||
- 성공 시: `작업중`, `브랜치준비`
|
||||
- 실패 시: `이슈`, 최근 오류 기록
|
||||
- `개발완료` 상태: worker가 `release` 브랜치 병합 시도
|
||||
- 병합 성공 시: `완료`
|
||||
- 병합 실패 시: `이슈`
|
||||
- `main` 반영 성공 시: `PLAN_MAIN_PROJECT_REPO_PATH`에서 `main` 브랜치 `pull --ff-only`까지 수행
|
||||
|
||||
안전 조건:
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
|
||||
import { buildPlanNotificationData } from './plan-notification-service.js';
|
||||
import { shouldNotifyPlanRestart } from './plan-notification-policy.js';
|
||||
import { shouldTriggerRetryFromActionNote } from './plan-retry-policy.js';
|
||||
import { issueActionSchema } from './plan-service.js';
|
||||
import { issueActionSchema, shouldUseLocalMainPlanMode } from './plan-service.js';
|
||||
|
||||
test('shouldTriggerRetryFromActionNote detects missing-fix and verification follow-up requests', () => {
|
||||
assert.equal(shouldTriggerRetryFromActionNote('누락된 거 다시 고쳐서 테스트해 줘'), true);
|
||||
@@ -52,3 +52,9 @@ test('buildPlanNotificationData uses stable task key per plan', () => {
|
||||
notificationKey: 'plan:17',
|
||||
});
|
||||
});
|
||||
|
||||
test('shouldUseLocalMainPlanMode keeps auto_worker on branch workflow', () => {
|
||||
process.env.PLAN_LOCAL_MAIN_MODE = 'true';
|
||||
assert.equal(shouldUseLocalMainPlanMode('auto_worker'), false);
|
||||
assert.equal(shouldUseLocalMainPlanMode('none'), true);
|
||||
});
|
||||
|
||||
@@ -268,6 +268,11 @@ export function buildPlanBranchName(workId: string, id: number) {
|
||||
return `${prefix}/plan-${id}-${token}`;
|
||||
}
|
||||
|
||||
export function shouldUseLocalMainPlanMode(automationType: unknown) {
|
||||
const env = getEnv();
|
||||
return Boolean(env.PLAN_LOCAL_MAIN_MODE) && normalizePlanAutomationType(automationType) !== 'auto_worker';
|
||||
}
|
||||
|
||||
export function mapPlanRow(
|
||||
row: Record<string, unknown>,
|
||||
options?: PlanRowOptions,
|
||||
@@ -2399,7 +2404,6 @@ export async function listPlanIssueSummaries(planItemIds: number[]) {
|
||||
|
||||
export async function claimNextPlanForBranch(workerId: string) {
|
||||
await ensurePlanTable();
|
||||
const env = getEnv();
|
||||
|
||||
return db.transaction(async (trx) => {
|
||||
const row = await trx(PLAN_TABLE)
|
||||
@@ -2417,7 +2421,9 @@ export async function claimNextPlanForBranch(workerId: string) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const assignedBranch = env.PLAN_LOCAL_MAIN_MODE ? env.PLAN_MAIN_BRANCH : buildPlanBranchName(String(row.work_id), Number(row.id));
|
||||
const assignedBranch = shouldUseLocalMainPlanMode(row.automation_type)
|
||||
? String(getEnv().PLAN_MAIN_BRANCH)
|
||||
: buildPlanBranchName(String(row.work_id), Number(row.id));
|
||||
const rows = await trx(PLAN_TABLE)
|
||||
.where({ id: row.id })
|
||||
.update({
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
markPlanReleaseMerged,
|
||||
markPlanMerged,
|
||||
markPlanWorkCompleted,
|
||||
shouldUseLocalMainPlanMode,
|
||||
upsertAutoPlanItem,
|
||||
} from '../services/plan-service.js';
|
||||
import {
|
||||
@@ -200,6 +201,10 @@ export class PlanWorker {
|
||||
return Boolean(getEnv().PLAN_LOCAL_MAIN_MODE);
|
||||
}
|
||||
|
||||
private shouldUseLocalMainModeForPlan(item: { automationType?: unknown }) {
|
||||
return shouldUseLocalMainPlanMode(item.automationType);
|
||||
}
|
||||
|
||||
start() {
|
||||
const env = getEnv();
|
||||
|
||||
@@ -613,8 +618,10 @@ export class PlanWorker {
|
||||
planId: number,
|
||||
workId: string,
|
||||
note: unknown,
|
||||
automationType: unknown,
|
||||
) {
|
||||
const env = getEnv();
|
||||
const useLocalMainMode = shouldUseLocalMainPlanMode(automationType);
|
||||
const runCodexCommandAttempt = async (attempt: number) =>
|
||||
await new Promise<string>((resolve, reject) => {
|
||||
let settled = false;
|
||||
@@ -629,7 +636,7 @@ export class PlanWorker {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
PLAN_REPO_PATH: env.PLAN_LOCAL_MAIN_MODE ? env.PLAN_MAIN_PROJECT_REPO_PATH : env.PLAN_GIT_REPO_PATH,
|
||||
PLAN_REPO_PATH: useLocalMainMode ? env.PLAN_MAIN_PROJECT_REPO_PATH : env.PLAN_GIT_REPO_PATH,
|
||||
PLAN_API_BASE_URL: 'http://127.0.0.1:3100/api',
|
||||
PLAN_ACCESS_TOKEN: ERROR_LOG_VIEW_TOKEN,
|
||||
PLAN_ITEM_ID: String(planId),
|
||||
@@ -637,7 +644,7 @@ export class PlanWorker {
|
||||
PLAN_CODEX_TEMPLATE_HOME: env.PLAN_CODEX_TEMPLATE_HOME,
|
||||
PLAN_GIT_USER_NAME: env.PLAN_GIT_USER_NAME,
|
||||
PLAN_GIT_USER_EMAIL: env.PLAN_GIT_USER_EMAIL,
|
||||
PLAN_LOCAL_MAIN_MODE: env.PLAN_LOCAL_MAIN_MODE ? 'true' : 'false',
|
||||
PLAN_LOCAL_MAIN_MODE: useLocalMainMode ? 'true' : 'false',
|
||||
PLAN_SKIP_WORK_COMPLETE: 'true',
|
||||
},
|
||||
});
|
||||
@@ -907,7 +914,7 @@ export class PlanWorker {
|
||||
const releaseTarget = String(item.releaseTarget ?? env.PLAN_RELEASE_BRANCH);
|
||||
|
||||
try {
|
||||
if (!this.isLocalMainMode()) {
|
||||
if (!this.shouldUseLocalMainModeForPlan(item)) {
|
||||
await cleanAutomationWorktree(env.PLAN_GIT_REPO_PATH);
|
||||
await ensureBranchExists(
|
||||
{
|
||||
@@ -926,8 +933,8 @@ export class PlanWorker {
|
||||
return;
|
||||
}
|
||||
this.logger.info(
|
||||
{ planId, branch: assignedBranch, localMainMode: this.isLocalMainMode() },
|
||||
this.isLocalMainMode() ? 'Plan local main execution prepared' : 'Plan branch created',
|
||||
{ planId, branch: assignedBranch, localMainMode: this.shouldUseLocalMainModeForPlan(item) },
|
||||
this.shouldUseLocalMainModeForPlan(item) ? 'Plan local main execution prepared' : 'Plan branch created',
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '브랜치 생성에 실패했습니다.';
|
||||
@@ -975,7 +982,7 @@ export class PlanWorker {
|
||||
const autoDeployToMain = Boolean(item.autoDeployToMain ?? true);
|
||||
|
||||
try {
|
||||
if (this.isLocalMainMode()) {
|
||||
if (this.shouldUseLocalMainModeForPlan(item)) {
|
||||
const completedRow = await markPlanAsCompleted(
|
||||
planId,
|
||||
'로컬 main 직접 작업 모드에서 release/main 반영 단계를 건너뛰고 완료 처리했습니다.',
|
||||
@@ -1071,7 +1078,7 @@ export class PlanWorker {
|
||||
const planLabel = formatPlanNotificationLabel(workId, planId);
|
||||
|
||||
try {
|
||||
if (this.isLocalMainMode()) {
|
||||
if (this.shouldUseLocalMainModeForPlan(item)) {
|
||||
const completedRow = await markPlanAsCompleted(
|
||||
planId,
|
||||
'로컬 main 직접 작업 모드에서 main 반영 단계를 건너뛰고 완료 처리했습니다.',
|
||||
@@ -1201,7 +1208,7 @@ export class PlanWorker {
|
||||
'work-started',
|
||||
);
|
||||
|
||||
const output = await this.runCodexCommandWithProgressNotifications(planId, workId, item.note);
|
||||
const output = await this.runCodexCommandWithProgressNotifications(planId, workId, item.note, item.automationType);
|
||||
|
||||
if (output.includes('처리할 Plan 항목이 없습니다.')) {
|
||||
throw new Error('자동 작업 대상 Plan 항목을 찾지 못했습니다. 상태 전환 로직을 확인해 주세요.');
|
||||
@@ -1229,7 +1236,7 @@ export class PlanWorker {
|
||||
return;
|
||||
}
|
||||
|
||||
const finalCompletedRow = this.isLocalMainMode()
|
||||
const finalCompletedRow = this.shouldUseLocalMainModeForPlan(item)
|
||||
? await markPlanAsCompleted(planId, '로컬 main 직접 작업으로 자동 작업을 완료했습니다.')
|
||||
: await markPlanWorkCompleted(planId, this.workerId, '자동 작업을 완료했습니다.');
|
||||
if (!finalCompletedRow) {
|
||||
@@ -1240,7 +1247,9 @@ export class PlanWorker {
|
||||
planId,
|
||||
workId,
|
||||
planLabel,
|
||||
this.isLocalMainMode() ? '자동 작업이 로컬 main 작업본에 직접 반영되어 완료되었습니다.' : this.buildExecutionCompletedBody(autoDeployToMain),
|
||||
this.shouldUseLocalMainModeForPlan(item)
|
||||
? '자동 작업이 로컬 main 작업본에 직접 반영되어 완료되었습니다.'
|
||||
: this.buildExecutionCompletedBody(autoDeployToMain),
|
||||
'work-completed',
|
||||
);
|
||||
this.logger.info({ planId }, 'Plan Codex execution completed');
|
||||
|
||||
94
scripts/capture-auth-utils.mjs
Normal file
94
scripts/capture-auth-utils.mjs
Normal file
@@ -0,0 +1,94 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
|
||||
const DEFAULT_CAPTURE_STORAGE_KEY = 'work-app.token-access.registered-token';
|
||||
const DEFAULT_CAPTURE_BASE_URL = 'https://test.sm-home.cloud/';
|
||||
|
||||
function stripWrappingQuotes(value) {
|
||||
if (!value) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
return value.slice(1, -1);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function applyEnvFile(filePath) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
|
||||
for (const rawLine of content.split(/\r?\n/u)) {
|
||||
const line = rawLine.trim();
|
||||
|
||||
if (!line || line.startsWith('#')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const separatorIndex = line.indexOf('=');
|
||||
|
||||
if (separatorIndex <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = line.slice(0, separatorIndex).trim();
|
||||
const value = stripWrappingQuotes(line.slice(separatorIndex + 1).trim());
|
||||
|
||||
if (!key || process.env[key] !== undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
function loadCaptureEnv(cwd = process.cwd()) {
|
||||
applyEnvFile(path.join(cwd, '.env'));
|
||||
applyEnvFile(path.join(cwd, 'etc/servers/work-server/.env'));
|
||||
}
|
||||
|
||||
export function getCaptureRuntimeConfig(cwd = process.cwd()) {
|
||||
loadCaptureEnv(cwd);
|
||||
|
||||
return {
|
||||
baseUrl:
|
||||
process.env.CAPTURE_BASE_URL?.trim() ||
|
||||
process.env.SERVER_COMMAND_TEST_URL?.trim() ||
|
||||
DEFAULT_CAPTURE_BASE_URL,
|
||||
tokenAccessStorageKey:
|
||||
process.env.CAPTURE_REGISTERED_ACCESS_STORAGE_KEY?.trim() || DEFAULT_CAPTURE_STORAGE_KEY,
|
||||
registeredAccessToken:
|
||||
process.env.CAPTURE_REGISTERED_ACCESS_TOKEN?.trim() ||
|
||||
process.env.VITE_ALLOWED_REGISTRATION_TOKEN?.trim() ||
|
||||
process.env.SERVER_COMMAND_ACCESS_TOKEN?.trim() ||
|
||||
'',
|
||||
};
|
||||
}
|
||||
|
||||
export async function createCaptureContext(browser, options = {}) {
|
||||
const { tokenAccessStorageKey, registeredAccessToken } = getCaptureRuntimeConfig();
|
||||
const context = await browser.newContext(options);
|
||||
|
||||
if (registeredAccessToken) {
|
||||
await context.addInitScript(
|
||||
({ accessToken, storageKey }) => {
|
||||
window.localStorage.setItem(storageKey, accessToken);
|
||||
},
|
||||
{
|
||||
accessToken: registeredAccessToken,
|
||||
storageKey: tokenAccessStorageKey,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import process from 'node:process';
|
||||
import { chromium } from 'playwright';
|
||||
import { createCaptureContext, getCaptureRuntimeConfig } from './capture-auth-utils.mjs';
|
||||
import { ensureDirectory, getKstDate, resolveCapturePaths, updateWorklogCaptureSection } from './worklog-capture-utils.mjs';
|
||||
|
||||
const cwd = process.cwd();
|
||||
const componentId = process.argv[2];
|
||||
const captureDate = process.argv[3] ?? getKstDate();
|
||||
const baseUrl = process.env.CAPTURE_BASE_URL ?? 'http://127.0.0.1:5174';
|
||||
const { baseUrl } = getCaptureRuntimeConfig(cwd);
|
||||
|
||||
if (!componentId) {
|
||||
console.error('Usage: node scripts/capture-component-screenshot.mjs <component-id> [YYYY-MM-DD]');
|
||||
@@ -21,13 +22,14 @@ const { screenshotDir, screenshotPath, worklogPath, markdownImagePath } = resolv
|
||||
const targetSelector = `#component-sample-${componentId}`;
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({
|
||||
const context = await createCaptureContext(browser, {
|
||||
viewport: {
|
||||
width: 1600,
|
||||
height: 1200,
|
||||
},
|
||||
deviceScaleFactor: 2,
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await ensureDirectory(screenshotDir);
|
||||
@@ -59,5 +61,6 @@ try {
|
||||
console.log(`Saved: ${screenshotPath}`);
|
||||
console.log(`Linked in: ${worklogPath}`);
|
||||
} finally {
|
||||
await context.close();
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import process from 'node:process';
|
||||
import { chromium } from 'playwright';
|
||||
import { createCaptureContext, getCaptureRuntimeConfig } from './capture-auth-utils.mjs';
|
||||
import { ensureDirectory, getKstDate, resolveCapturePaths, updateWorklogCaptureSection } from './worklog-capture-utils.mjs';
|
||||
|
||||
const FEATURE_CAPTURE_PRESETS = {
|
||||
@@ -56,7 +57,7 @@ const FEATURE_CAPTURE_PRESETS = {
|
||||
const cwd = process.cwd();
|
||||
const presetKey = process.argv[2];
|
||||
const captureDate = process.argv[3] ?? getKstDate();
|
||||
const baseUrl = process.env.CAPTURE_BASE_URL ?? 'http://127.0.0.1:5174';
|
||||
const { baseUrl } = getCaptureRuntimeConfig(cwd);
|
||||
|
||||
if (!presetKey || !(presetKey in FEATURE_CAPTURE_PRESETS)) {
|
||||
console.error(`Usage: node scripts/capture-feature-screenshot.mjs <${Object.keys(FEATURE_CAPTURE_PRESETS).join('|')}> [YYYY-MM-DD]`);
|
||||
@@ -71,10 +72,11 @@ const { screenshotDir, screenshotPath, worklogPath, markdownImagePath } = resolv
|
||||
});
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({
|
||||
const context = await createCaptureContext(browser, {
|
||||
viewport: { width: 1600, height: 1200 },
|
||||
deviceScaleFactor: 2,
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await ensureDirectory(screenshotDir);
|
||||
@@ -113,5 +115,6 @@ try {
|
||||
console.log(`Saved: ${screenshotPath}`);
|
||||
console.log(`Linked in: ${worklogPath}`);
|
||||
} finally {
|
||||
await context.close();
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import process from 'node:process';
|
||||
import { chromium } from 'playwright';
|
||||
import { createCaptureContext, getCaptureRuntimeConfig } from './capture-auth-utils.mjs';
|
||||
import { ensureDirectory, getKstDate, resolveCapturePaths, updateWorklogCaptureSection } from './worklog-capture-utils.mjs';
|
||||
|
||||
const cwd = process.cwd();
|
||||
const captureDate = process.argv[2] ?? getKstDate();
|
||||
const baseUrl = process.env.CAPTURE_BASE_URL ?? 'http://127.0.0.1:5174';
|
||||
const { baseUrl } = getCaptureRuntimeConfig(cwd);
|
||||
const screenshotFileName = 'main-content-fullscreen-toggle.png';
|
||||
const { screenshotDir, screenshotPath, worklogPath, markdownImagePath } = resolveCapturePaths({
|
||||
cwd,
|
||||
@@ -13,10 +14,11 @@ const { screenshotDir, screenshotPath, worklogPath, markdownImagePath } = resolv
|
||||
});
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({
|
||||
const context = await createCaptureContext(browser, {
|
||||
viewport: { width: 1600, height: 1200 },
|
||||
deviceScaleFactor: 2,
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await ensureDirectory(screenshotDir);
|
||||
@@ -41,5 +43,6 @@ try {
|
||||
console.log(`Saved: ${screenshotPath}`);
|
||||
console.log(`Linked in: ${worklogPath}`);
|
||||
} finally {
|
||||
await context.close();
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import process from 'node:process';
|
||||
import { chromium } from 'playwright';
|
||||
import { createCaptureContext, getCaptureRuntimeConfig } from './capture-auth-utils.mjs';
|
||||
import { ensureDirectory, getKstDate, resolveCapturePaths, updateWorklogCaptureSection } from './worklog-capture-utils.mjs';
|
||||
|
||||
const cwd = process.cwd();
|
||||
const menuGroup = process.argv[2] ?? 'docs';
|
||||
const captureDate = process.argv[3] ?? getKstDate();
|
||||
const baseUrl = process.env.CAPTURE_BASE_URL ?? 'http://127.0.0.1:5174';
|
||||
const { baseUrl } = getCaptureRuntimeConfig(cwd);
|
||||
|
||||
const supportedMenuGroups = new Set(['docs', 'plans']);
|
||||
|
||||
@@ -22,10 +23,11 @@ const { screenshotDir, screenshotPath, worklogPath, markdownImagePath } = resolv
|
||||
});
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({
|
||||
const context = await createCaptureContext(browser, {
|
||||
viewport: { width: 1600, height: 1200 },
|
||||
deviceScaleFactor: 2,
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await ensureDirectory(screenshotDir);
|
||||
@@ -57,5 +59,6 @@ try {
|
||||
console.log(`Saved: ${screenshotPath}`);
|
||||
console.log(`Linked in: ${worklogPath}`);
|
||||
} finally {
|
||||
await context.close();
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import process from 'node:process';
|
||||
import { chromium } from 'playwright';
|
||||
import { createCaptureContext, getCaptureRuntimeConfig } from './capture-auth-utils.mjs';
|
||||
import { ensureDirectory, getKstDate, resolveCapturePaths, updateWorklogCaptureSection } from './worklog-capture-utils.mjs';
|
||||
|
||||
const cwd = process.cwd();
|
||||
const captureDate = process.argv[2] ?? getKstDate();
|
||||
const baseUrl = process.env.CAPTURE_BASE_URL ?? 'http://127.0.0.1:5174';
|
||||
const { baseUrl } = getCaptureRuntimeConfig(cwd);
|
||||
const screenshotFileName = 'plan-board-mobile-memo-detail.png';
|
||||
const { screenshotDir, screenshotPath, worklogPath, markdownImagePath } = resolveCapturePaths({
|
||||
cwd,
|
||||
@@ -13,7 +14,7 @@ const { screenshotDir, screenshotPath, worklogPath, markdownImagePath } = resolv
|
||||
});
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({
|
||||
const context = await createCaptureContext(browser, {
|
||||
viewport: { width: 430, height: 932 },
|
||||
isMobile: true,
|
||||
hasTouch: true,
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import process from 'node:process';
|
||||
import { chromium } from 'playwright';
|
||||
import { createCaptureContext, getCaptureRuntimeConfig } from './capture-auth-utils.mjs';
|
||||
import { ensureDirectory, getKstDate, resolveCapturePaths, updateWorklogCaptureSection } from './worklog-capture-utils.mjs';
|
||||
|
||||
const cwd = process.cwd();
|
||||
const captureDate = process.argv[2] ?? getKstDate();
|
||||
const baseUrl = process.env.CAPTURE_BASE_URL ?? 'http://127.0.0.1:5174';
|
||||
const { baseUrl } = getCaptureRuntimeConfig(cwd);
|
||||
const screenshotFileName = 'search-command.png';
|
||||
const { screenshotDir, screenshotPath, worklogPath, markdownImagePath } = resolveCapturePaths({
|
||||
cwd,
|
||||
@@ -13,7 +14,7 @@ const { screenshotDir, screenshotPath, worklogPath, markdownImagePath } = resolv
|
||||
});
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({
|
||||
const context = await createCaptureContext(browser, {
|
||||
viewport: { width: 430, height: 932 },
|
||||
isMobile: true,
|
||||
hasTouch: true,
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import process from 'node:process';
|
||||
import { chromium } from 'playwright';
|
||||
import { createCaptureContext, getCaptureRuntimeConfig } from './capture-auth-utils.mjs';
|
||||
import { ensureDirectory, getKstDate, resolveCapturePaths, updateWorklogCaptureSection } from './worklog-capture-utils.mjs';
|
||||
|
||||
const ACCESS_TOKEN = 'usr_7f3a9c2d8e1b4a6f';
|
||||
const TOKEN_ACCESS_STORAGE_KEY = 'work-app.token-access.registered-token';
|
||||
|
||||
const SETTINGS_CAPTURE_PRESETS = {
|
||||
automation: {
|
||||
screenshotFileName: 'settings-app.png',
|
||||
@@ -19,7 +17,7 @@ const SETTINGS_CAPTURE_PRESETS = {
|
||||
const cwd = process.cwd();
|
||||
const presetKey = process.argv[2];
|
||||
const captureDate = process.argv[3] ?? getKstDate();
|
||||
const baseUrl = process.env.CAPTURE_BASE_URL ?? 'http://127.0.0.1:4173';
|
||||
const { baseUrl } = getCaptureRuntimeConfig(cwd);
|
||||
|
||||
if (!presetKey || !(presetKey in SETTINGS_CAPTURE_PRESETS)) {
|
||||
console.error(`Usage: node scripts/capture-settings-screenshot.mjs <${Object.keys(SETTINGS_CAPTURE_PRESETS).join('|')}> [YYYY-MM-DD]`);
|
||||
@@ -34,21 +32,11 @@ const { screenshotDir, screenshotPath, worklogPath, markdownImagePath } = resolv
|
||||
});
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({
|
||||
const context = await createCaptureContext(browser, {
|
||||
viewport: { width: 1600, height: 1200 },
|
||||
deviceScaleFactor: 2,
|
||||
});
|
||||
|
||||
await context.addInitScript(
|
||||
({ tokenAccessStorageKey, accessToken }) => {
|
||||
window.localStorage.setItem(tokenAccessStorageKey, accessToken);
|
||||
},
|
||||
{
|
||||
tokenAccessStorageKey: TOKEN_ACCESS_STORAGE_KEY,
|
||||
accessToken: ACCESS_TOKEN,
|
||||
},
|
||||
);
|
||||
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
|
||||
@@ -676,7 +676,9 @@ export function ChatSourceChangesPage() {
|
||||
>
|
||||
<Space direction="vertical" size={6} style={{ width: '100%' }}>
|
||||
<Space size={8} wrap>
|
||||
<Text strong>{entry.conversationTitle}</Text>
|
||||
<Text strong className="chat-source-changes-page__list-title">
|
||||
{entry.conversationTitle}
|
||||
</Text>
|
||||
<Tag color={entry.status === 'failed' ? 'error' : 'blue'}>{entry.status}</Tag>
|
||||
<Tag color={entry.currentSourceStatus === 'applied' ? 'cyan' : 'default'}>
|
||||
{entry.currentSourceStatus === 'applied' ? '현재 소스 적용' : '현재 소스 미적용'}
|
||||
@@ -702,7 +704,7 @@ export function ChatSourceChangesPage() {
|
||||
{selectedEntry ? (
|
||||
<Space direction="vertical" size={16} className="chat-source-changes-page__detail">
|
||||
<Space direction="vertical" size={4}>
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
<Title level={5} className="chat-source-changes-page__detail-title">
|
||||
{selectedEntry.requestTitle}
|
||||
</Title>
|
||||
<Text type="secondary">
|
||||
|
||||
@@ -2149,6 +2149,22 @@
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.app-chat-panel__resource-strip-filter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
color: #334155;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.app-chat-panel__resource-strip-filter .ant-checkbox-wrapper {
|
||||
width: 100%;
|
||||
margin-inline-start: 0;
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.app-chat-panel__resource-strip-empty.ant-typography {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
|
||||
@@ -484,12 +484,18 @@ function formatDateTimeLabel(value: string | null) {
|
||||
|
||||
function getServerVersionStatusClassName(item: ServerCommandItem | null) {
|
||||
if (!item) {
|
||||
return 'app-header__server-version-indicator--stale';
|
||||
return 'app-header__server-version-indicator--unknown';
|
||||
}
|
||||
|
||||
return item.buildRequired || item.updateAvailable
|
||||
? 'app-header__server-version-indicator--stale'
|
||||
: 'app-header__server-version-indicator--latest';
|
||||
if (item.buildRequired) {
|
||||
return 'app-header__server-version-indicator--build-required';
|
||||
}
|
||||
|
||||
if (item.updateAvailable) {
|
||||
return 'app-header__server-version-indicator--update-available';
|
||||
}
|
||||
|
||||
return 'app-header__server-version-indicator--latest';
|
||||
}
|
||||
|
||||
function getServerLastSourceChangedDateLabel(item: ServerCommandItem | null) {
|
||||
@@ -501,8 +507,12 @@ function getServerVersionStatusTitle(item: ServerCommandItem | null, label: stri
|
||||
return `${label} 최신 버전 확인 전`;
|
||||
}
|
||||
|
||||
if (item.buildRequired || item.updateAvailable) {
|
||||
return `${label} 최신 버전 아님`;
|
||||
if (item.buildRequired) {
|
||||
return `${label} 커밋 미반영 상태`;
|
||||
}
|
||||
|
||||
if (item.updateAvailable) {
|
||||
return `${label} 운영 반영 대기 상태`;
|
||||
}
|
||||
|
||||
return `${label} 최신 버전`;
|
||||
|
||||
@@ -228,7 +228,15 @@
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.app-header__server-version-indicator--stale {
|
||||
.app-header__server-version-indicator--unknown {
|
||||
background: #94a3b8;
|
||||
}
|
||||
|
||||
.app-header__server-version-indicator--update-available {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
.app-header__server-version-indicator--build-required {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
ThunderboltOutlined,
|
||||
UpOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Alert, Button, Input, Select, Spin, message } from 'antd';
|
||||
import { Alert, Button, Checkbox, Input, Select, Spin, message } from 'antd';
|
||||
import type { TextAreaRef } from 'antd/es/input/TextArea';
|
||||
import {
|
||||
useEffect,
|
||||
@@ -159,6 +159,16 @@ function buildInlinePreviewLabel(url: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function buildPreviewFileName(item: PreviewOption) {
|
||||
try {
|
||||
const parsed = new URL(item.url, typeof window !== 'undefined' ? window.location.origin : 'https://local.invalid');
|
||||
const fileName = parsed.pathname.split('/').filter(Boolean).at(-1)?.trim();
|
||||
return fileName || item.label.trim() || item.url;
|
||||
} catch {
|
||||
return item.label.trim() || item.url;
|
||||
}
|
||||
}
|
||||
|
||||
async function createPreviewFetchError(response: Response): Promise<PreviewFetchError> {
|
||||
const contentType = response.headers.get('content-type')?.toLowerCase() ?? '';
|
||||
let responseMessage = '';
|
||||
@@ -750,6 +760,7 @@ export function ChatConversationView({
|
||||
const [collapsedActivityRequestIds, setCollapsedActivityRequestIds] = useState<string[]>([]);
|
||||
const [collapsibleMessageIds, setCollapsibleMessageIds] = useState<number[]>([]);
|
||||
const [showBusyOverlay, setShowBusyOverlay] = useState(false);
|
||||
const [showLatestResourceOnly, setShowLatestResourceOnly] = useState(true);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const activitySectionRefs = useRef(new Map<string, HTMLElement>());
|
||||
const messageBodyRefs = useRef(new Map<number, HTMLDivElement>());
|
||||
@@ -808,6 +819,23 @@ export function ChatConversationView({
|
||||
return [...ordered, ...orphanActivityMessages];
|
||||
}, [visibleMessages]);
|
||||
const previewItemsByUrl = useMemo(() => new Map(previewItems.map((item) => [item.url, item])), [previewItems]);
|
||||
const visiblePreviewItems = useMemo(() => {
|
||||
if (!showLatestResourceOnly) {
|
||||
return previewItems;
|
||||
}
|
||||
|
||||
const seenFileNames = new Set<string>();
|
||||
return previewItems.filter((item) => {
|
||||
const fileName = buildPreviewFileName(item);
|
||||
|
||||
if (seenFileNames.has(fileName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
seenFileNames.add(fileName);
|
||||
return true;
|
||||
});
|
||||
}, [previewItems, showLatestResourceOnly]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') {
|
||||
@@ -1150,8 +1178,19 @@ export function ChatConversationView({
|
||||
{isResourceStripOpen ? (
|
||||
<div className="app-chat-panel__resource-strip">
|
||||
{previewItems.length > 0 ? (
|
||||
<div className="app-chat-panel__resource-strip-list">
|
||||
{previewItems.map((item) => (
|
||||
<>
|
||||
<label className="app-chat-panel__resource-strip-filter">
|
||||
<Checkbox
|
||||
checked={showLatestResourceOnly}
|
||||
onChange={(event) => {
|
||||
setShowLatestResourceOnly(event.target.checked);
|
||||
}}
|
||||
>
|
||||
같은 파일명은 최종 리소스만 보기
|
||||
</Checkbox>
|
||||
</label>
|
||||
<div className="app-chat-panel__resource-strip-list">
|
||||
{visiblePreviewItems.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
@@ -1163,8 +1202,9 @@ export function ChatConversationView({
|
||||
<span>{item.label}</span>
|
||||
<span>{item.kind}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<span className="app-chat-panel__resource-strip-empty">
|
||||
현재 대화에 바로 열 수 있는 리소스가 없습니다.
|
||||
|
||||
@@ -2,7 +2,8 @@ import { useEffect, useState } from 'react';
|
||||
|
||||
export const TOKEN_ACCESS_STORAGE_KEY = 'work-app.token-access.registered-token';
|
||||
export const TOKEN_ACCESS_SYNC_EVENT = 'work-app:token-access-changed';
|
||||
export const ALLOWED_REGISTRATION_TOKEN = 'usr_7f3a9c2d8e1b4a6f';
|
||||
export const ALLOWED_REGISTRATION_TOKEN =
|
||||
import.meta.env.VITE_ALLOWED_REGISTRATION_TOKEN?.trim() || 'usr_7f3a9c2d8e1b4a6f';
|
||||
|
||||
function normalizeToken(value: string | null | undefined) {
|
||||
return value?.trim() ?? '';
|
||||
|
||||
@@ -992,6 +992,12 @@ button,
|
||||
border-color: rgba(22, 93, 255, 0.22);
|
||||
}
|
||||
|
||||
.chat-source-changes-page__list-title.ant-typography,
|
||||
.chat-source-changes-page__detail-title.ant-typography {
|
||||
margin: 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.chat-source-changes-page__detail {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
@@ -1628,6 +1634,16 @@ button,
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.chat-source-changes-page__list-title.ant-typography {
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.chat-source-changes-page__detail-title.ant-typography {
|
||||
font-size: 16px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.release-review-page__grid {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
8
src/vite-env.d.ts
vendored
8
src/vite-env.d.ts
vendored
@@ -1,2 +1,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite-plugin-pwa/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_ALLOWED_REGISTRATION_TOKEN?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user