Compare commits
8 Commits
feature/sp
...
64515333ae
| Author | SHA1 | Date | |
|---|---|---|---|
| 64515333ae | |||
| 09dbb57cd7 | |||
| b34da9a288 | |||
| 78ca4a72bb | |||
| c8f82ba9a8 | |||
| a40b6eda32 | |||
| c2a60f910b | |||
| fb21678870 |
24
.plans/sprints/HOTFIX-005.md
Normal file
24
.plans/sprints/HOTFIX-005.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# HOTFIX-005: 프로젝트 카드/이력 UI 보정
|
||||
|
||||
## 목표
|
||||
프로젝트 목록의 잘못된 자매 프로필 사진 사용을 제거하고, 프로젝트 상세에서 Sprint timeline과 Hotfix history를 분리해 정보 구조를 자연스럽게 정리
|
||||
|
||||
## 태스크
|
||||
|
||||
### TASK-061: 프로젝트 카드 대표 아바타 제거
|
||||
- 프로젝트 목록에서 자매 프로필 사진 제거
|
||||
- 프로젝트 전용 placeholder / initial / neutral symbol로 교체
|
||||
- 자매 프로필 사진은 자매 관련 화면에만 유지
|
||||
|
||||
### TASK-062: 프로젝트 상세 이력 구조 분리
|
||||
- Sprint timeline은 Sprint만 표시
|
||||
- Hotfix는 별도 `HOTFIX HISTORY` 섹션으로 분리
|
||||
- 현재 `TIMELINE` 라벨 반복/중복 구조 제거
|
||||
- Hotfix는 별도 태그/스타일로 간결하게 표시
|
||||
|
||||
## 검증 기준
|
||||
- 프로젝트 목록에서 자매 프로필 사진 제거 확인
|
||||
- 프로젝트 상세에서 Sprint timeline과 Hotfix history 분리 확인
|
||||
- 반복되는 `TIMELINE` 라벨 제거
|
||||
- 빌드/테스트 통과
|
||||
- 외부 URL QA 필수
|
||||
50
.plans/sprints/SPRINT-013.md
Normal file
50
.plans/sprints/SPRINT-013.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# SPRINT-013: 자매 관리/타임라인/선택 UX 정리
|
||||
|
||||
## 목표
|
||||
자매 상세 탭 단순화, 관리자 자매 관리 카드의 이모지 제거, 프로젝트 진행 이력에 Hotfix 표시, 불필요한 텍스트 드래그 방지
|
||||
|
||||
## 태스크
|
||||
|
||||
### TASK-057: 자매 상세 탭 정리
|
||||
- `/sisters/[name]` 탭에서 `설정` 제거
|
||||
- 유지 탭:
|
||||
- 개요
|
||||
- 세션
|
||||
- 활동
|
||||
- 하네스
|
||||
- 로그
|
||||
- 탭 순서/간격 정리
|
||||
|
||||
### TASK-058: 관리자 자매 관리 카드 아이콘 교체
|
||||
- 관리 > 자매 관리 페이지의 여우/고양이/늑대 이모지 제거
|
||||
- 각 자매 실제 프로필 사진 사용
|
||||
- 이미지 없을 때만 fallback
|
||||
- 자매 관리 카드와 자매 상세의 시각적 일관성 맞추기
|
||||
|
||||
### TASK-059: 프로젝트 진행 이력에 Hotfix 표시
|
||||
- 프로젝트 상세의 timeline/phase/history에 Sprint뿐 아니라 Hotfix도 표시
|
||||
- 예시:
|
||||
- Sprint 001
|
||||
- Sprint 002
|
||||
- HOTFIX-001
|
||||
- Sprint 003
|
||||
- HOTFIX-002
|
||||
- Sprint/Hotfix를 함께 보여 프로젝트 실제 진행 이력 반영
|
||||
- UI에서 Hotfix는 별도 태그/스타일로 구분
|
||||
|
||||
### TASK-060: user-select 정책 정리
|
||||
- 기본적으로 텍스트 드래그 방지 (`user-select: none`)
|
||||
- 예외 허용 영역:
|
||||
- 로그 뷰어
|
||||
- 코드/하네스 편집기
|
||||
- input / textarea
|
||||
- 복사 가능한 코드 블록
|
||||
- 버튼/카드/탭/라벨/네비는 드래그 방지
|
||||
|
||||
## 검증 기준
|
||||
- 자매 상세 탭에서 설정 제거 확인
|
||||
- 관리자 자매 관리 카드에 실제 프로필 사진 반영
|
||||
- 프로젝트 상세에서 Hotfix 포함 이력 표시
|
||||
- 로그/편집칸 외 텍스트 드래그 불가
|
||||
- 빌드/테스트 통과
|
||||
- 외부 URL QA 필수
|
||||
33
.qa/SPRINT-013-review-1.md
Normal file
33
.qa/SPRINT-013-review-1.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# SPRINT-013 QA Review — Iteration 1
|
||||
|
||||
- **검증일시:** 2026-04-04 21:15 KST
|
||||
- **검증자:** 다랑이 (Evaluator)
|
||||
- **결과:** ❌ FAILED
|
||||
|
||||
## 검증 항목
|
||||
|
||||
| 항목 | 결과 |
|
||||
|------|------|
|
||||
| npm test | ✅ 23/23 pass |
|
||||
| npm run build (BE) | ✅ |
|
||||
| npm run build (FE) | ✅ 16 routes |
|
||||
|
||||
## 확인된 사항
|
||||
|
||||
| Task | 내용 | 확인 |
|
||||
|------|------|------|
|
||||
| TASK-057 | 자매 상세 설정 탭 제거 | ✅ |
|
||||
| TASK-059 | 프로젝트 이력에 Sprint + Hotfix 표시 | ✅ `/api/projects/:id/history` + timeline UI |
|
||||
| TASK-060 | user-select 정책 정리 | ✅ GlobalStyle 기본 none + 예외 text |
|
||||
|
||||
## Blocking
|
||||
|
||||
| ID | 이슈 |
|
||||
|----|------|
|
||||
| B1 | **관리 페이지 이모지 제거 미완료** — `frontend/app/admin/logs/page.tsx`에 `🦊/🐱/🐺` label 잔존 |
|
||||
| B2 | **관리 페이지 이모지 제거 미완료** — `frontend/app/admin/harness/page.tsx`에 `🦊/🐱/🐺` label 잔존 |
|
||||
|
||||
## 판정
|
||||
- 자기야가 정정한 요구는 "프로젝트 아이콘"이 아니라 **관리 쪽 자매 아이콘(여우/고양이/늑대) 제거**였어.
|
||||
- admin 메인 카드에는 SisterAvatar가 적용됐지만, **admin logs / admin harness selector에는 여전히 이모지 label이 남아 있음**.
|
||||
- 즉 TASK-058 요구 반영이 완결되지 않았어.
|
||||
17
.qa/SPRINT-013-review-2.md
Normal file
17
.qa/SPRINT-013-review-2.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# SPRINT-013 QA Review — Iteration 2
|
||||
|
||||
- **검증일시:** 2026-04-04 21:18 KST
|
||||
- **검증자:** 다랑이 (Evaluator)
|
||||
- **결과:** ✅ PASSED
|
||||
|
||||
## 재검증 항목
|
||||
|
||||
| 항목 | 결과 |
|
||||
|------|------|
|
||||
| B1 | `frontend/app/admin/logs/page.tsx` 이모지 제거 | ✅ |
|
||||
| B2 | `frontend/app/admin/harness/page.tsx` 이모지 제거 | ✅ |
|
||||
| npm run build (FE) | ✅ 16 routes |
|
||||
|
||||
## 판정
|
||||
- admin logs / admin harness selector의 `🦊/🐱/🐺` 이모지 잔존 문제 해결됨
|
||||
- SPRINT-013 전체 요구사항 기준 blocking 0건
|
||||
@@ -44,6 +44,11 @@ export class ProjectsController {
|
||||
return this.projectsService.getProjectPulls(id, state ?? 'open');
|
||||
}
|
||||
|
||||
@Get(':id/history')
|
||||
getHistory(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.projectsService.getProjectHistory(id);
|
||||
}
|
||||
|
||||
@Post(':id/sync-sprints')
|
||||
@UseGuards(JwtGuard, RoleGuard)
|
||||
@Roles('admin')
|
||||
|
||||
@@ -104,6 +104,25 @@ export class ProjectsService {
|
||||
return this.gitea.getPulls(repoName, state);
|
||||
}
|
||||
|
||||
async getProjectHistory(id: number) {
|
||||
const project = await this.getProjectMeta(id);
|
||||
const repoName = project.repoUrl.replace(/\/+$/, '').split('/').pop() ?? '';
|
||||
const files = await this.gitea.getRepoTree(repoName, '.plans/sprints/');
|
||||
return files
|
||||
.filter((f) => /(SPRINT-\d+|HOTFIX-\d+)\.md$/i.test(f))
|
||||
.map((f) => {
|
||||
const name = f.split('/').pop() ?? '';
|
||||
const sprint = name.match(/SPRINT-(\d+)/i);
|
||||
const hotfix = name.match(/HOTFIX-(\d+)/i);
|
||||
return {
|
||||
kind: sprint ? 'sprint' : 'hotfix',
|
||||
order: sprint ? parseInt(sprint[1], 10) : 1000 + parseInt(hotfix?.[1] ?? '0', 10),
|
||||
label: name.replace('.md', ''),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.order - b.order);
|
||||
}
|
||||
|
||||
private async getProjectMeta(id: number) {
|
||||
const project = await this.prisma.project.findUnique({ where: { id }, select: { id: true, repoUrl: true } });
|
||||
if (!project) throw new NotFoundException(`Project ${id} not found`);
|
||||
|
||||
@@ -9,7 +9,7 @@ const SISTERS = ['harang', 'narang', 'darang', 'erang'];
|
||||
const FILES = ['SOUL.md', 'AGENTS.md', 'TOOLS.md', 'PROTOCOL.md', 'HEARTBEAT.md'];
|
||||
|
||||
const DISPLAY_NAMES: Record<string, string> = {
|
||||
harang: '🦊 하랑이', narang: '🦊 나랑이', darang: '🐱 다랑이', erang: '🐺 이랑이',
|
||||
harang: '하랑이', narang: '나랑이', darang: '다랑이', erang: '이랑이',
|
||||
};
|
||||
|
||||
const Layout = styled.div`
|
||||
|
||||
@@ -6,10 +6,10 @@ import LogTerminal from '@/components/admin/LogTerminal';
|
||||
import { adminFetch } from '@/lib/adminFetch';
|
||||
|
||||
const SISTERS = [
|
||||
{ value: 'harang', label: '🦊 하랑이' },
|
||||
{ value: 'narang', label: '🦊 나랑이' },
|
||||
{ value: 'darang', label: '🐱 다랑이' },
|
||||
{ value: 'erang', label: '🐺 이랑이' },
|
||||
{ value: 'harang', label: '하랑이' },
|
||||
{ value: 'narang', label: '나랑이' },
|
||||
{ value: 'darang', label: '다랑이' },
|
||||
{ value: 'erang', label: '이랑이' },
|
||||
];
|
||||
|
||||
const Controls = styled.div`
|
||||
|
||||
@@ -6,7 +6,8 @@ import StatusBadge from '@/components/common/StatusBadge';
|
||||
import ConfirmModal from '@/components/admin/ConfirmModal';
|
||||
import { API_URL } from '@/lib/config';
|
||||
import { adminFetch } from '@/lib/adminFetch';
|
||||
import { SISTER_EMOJIS, SISTER_DISPLAY_NAMES } from '@/lib/sisters';
|
||||
import { SISTER_DISPLAY_NAMES } from '@/lib/sisters';
|
||||
import SisterAvatar from '@/components/common/SisterAvatar';
|
||||
|
||||
type Status = 'online' | 'offline' | 'working' | 'unknown';
|
||||
|
||||
@@ -16,71 +17,13 @@ interface ActionResult {
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const Table = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
const Row = styled.div`
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
`;
|
||||
|
||||
const SisterInfo = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
`;
|
||||
|
||||
const Emoji = styled.span`font-size: 22px;`;
|
||||
|
||||
const Name = styled.span`
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
min-width: 70px;
|
||||
`;
|
||||
|
||||
const Actions = styled.div`
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
const ActionBtn = styled.button<{ $danger?: boolean }>`
|
||||
padding: 6px 14px;
|
||||
border-radius: 7px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: 1px solid;
|
||||
transition: all 0.15s;
|
||||
|
||||
${({ $danger }) =>
|
||||
$danger
|
||||
? `background: rgba(255,23,68,0.08); border-color: rgba(255,23,68,0.3); color: #FF1744;
|
||||
&:hover { background: rgba(255,23,68,0.18); }`
|
||||
: `background: rgba(88,166,255,0.08); border-color: rgba(88,166,255,0.3); color: #58A6FF;
|
||||
&:hover { background: rgba(88,166,255,0.18); }`}
|
||||
`;
|
||||
|
||||
const ResultBanner = styled.div<{ $success: boolean }>`
|
||||
margin-top: 8px;
|
||||
padding: 8px 12px;
|
||||
background: ${({ $success }) => $success ? 'rgba(0,230,118,0.08)' : 'rgba(255,23,68,0.08)'};
|
||||
border: 1px solid ${({ $success }) => $success ? 'rgba(0,230,118,0.3)' : 'rgba(255,23,68,0.3)'};
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
color: ${({ $success }) => $success ? '#00FF00' : '#FF1744'};
|
||||
font-family: monospace;
|
||||
white-space: pre-wrap;
|
||||
`;
|
||||
const Table = styled.div`display:flex;flex-direction:column;gap:8px;user-select:none;`;
|
||||
const Row = styled.div`background:var(--bg-surface);border:1px solid var(--border-color);border-radius:10px;padding:16px 20px;display:flex;align-items:center;gap:16px;`;
|
||||
const SisterInfo = styled.div`display:flex;align-items:center;gap:10px;flex:1;`;
|
||||
const Name = styled.span`font-size:15px;font-weight:600;color:var(--text-primary);min-width:70px;`;
|
||||
const Actions = styled.div`display:flex;gap:8px;`;
|
||||
const ActionBtn = styled.button<{ $danger?: boolean }>`padding:6px 14px;border-radius:7px;font-size:13px;font-weight:600;cursor:pointer;border:1px solid;transition:all .15s;user-select:none;${({ $danger }) => $danger ? `background: rgba(255,23,68,0.08); border-color: rgba(255,23,68,0.3); color: #FF1744; &:hover { background: rgba(255,23,68,0.18); }` : `background: rgba(88,166,255,0.08); border-color: rgba(88,166,255,0.3); color: #58A6FF; &:hover { background: rgba(88,166,255,0.18); }`}`;
|
||||
const ResultBanner = styled.div<{ $success: boolean }>`margin-top:8px;padding:8px 12px;background:${({ $success }) => $success ? 'rgba(0,230,118,0.08)' : 'rgba(255,23,68,0.08)'};border:1px solid ${({ $success }) => $success ? 'rgba(0,230,118,0.3)' : 'rgba(255,23,68,0.3)'};border-radius:6px;font-size:12px;color:${({ $success }) => $success ? '#00FF00' : '#FF1744'};font-family:monospace;white-space:pre-wrap;`;
|
||||
|
||||
export default function AdminSistersPage() {
|
||||
const [sisters, setSisters] = useState<any[]>([]);
|
||||
@@ -90,9 +33,7 @@ export default function AdminSistersPage() {
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_URL}/api/sisters`).then((r) => r.json()).then(setSisters).catch(() => {});
|
||||
const iv = setInterval(() => {
|
||||
fetch(`${API_URL}/api/sisters`).then((r) => r.json()).then(setSisters).catch(() => {});
|
||||
}, 15000);
|
||||
const iv = setInterval(() => { fetch(`${API_URL}/api/sisters`).then((r) => r.json()).then(setSisters).catch(() => {}); }, 15000);
|
||||
return () => clearInterval(iv);
|
||||
}, []);
|
||||
|
||||
@@ -104,10 +45,7 @@ export default function AdminSistersPage() {
|
||||
const data: ActionResult = await res.json();
|
||||
setResults((p) => ({ ...p, [`${action}:${name}`]: data }));
|
||||
} catch (e) {
|
||||
setResults((p) => ({
|
||||
...p,
|
||||
[`${action}:${name}`]: { success: false, output: '', error: (e as Error).message },
|
||||
}));
|
||||
setResults((p) => ({ ...p, [`${action}:${name}`]: { success: false, output: '', error: (e as Error).message } }));
|
||||
} finally {
|
||||
setLoading((p) => ({ ...p, [`${action}:${name}`]: false }));
|
||||
}
|
||||
@@ -123,52 +61,23 @@ export default function AdminSistersPage() {
|
||||
<div key={s.id}>
|
||||
<Row>
|
||||
<SisterInfo>
|
||||
<Emoji>{SISTER_EMOJIS[s.name] ?? '🤖'}</Emoji>
|
||||
<SisterAvatar name={s.name} size={28} />
|
||||
<Name>{SISTER_DISPLAY_NAMES[s.name] ?? s.name}</Name>
|
||||
<StatusBadge status={s.status as Status} />
|
||||
</SisterInfo>
|
||||
<Actions>
|
||||
<ActionBtn
|
||||
onClick={() => setModal({ action: 'restart', name: s.name })}
|
||||
disabled={loading[`restart:${s.name}`]}
|
||||
>
|
||||
{loading[`restart:${s.name}`] ? '...' : '🔄 재시작'}
|
||||
</ActionBtn>
|
||||
<ActionBtn
|
||||
$danger
|
||||
onClick={() => setModal({ action: 'reset', name: s.name })}
|
||||
disabled={loading[`reset:${s.name}`]}
|
||||
>
|
||||
{loading[`reset:${s.name}`] ? '...' : '🗑️ 리셋'}
|
||||
</ActionBtn>
|
||||
<ActionBtn onClick={() => setModal({ action: 'restart', name: s.name })} disabled={loading[`restart:${s.name}`]}>{loading[`restart:${s.name}`] ? '...' : '재시작'}</ActionBtn>
|
||||
<ActionBtn $danger onClick={() => setModal({ action: 'reset', name: s.name })} disabled={loading[`reset:${s.name}`]}>{loading[`reset:${s.name}`] ? '...' : '리셋'}</ActionBtn>
|
||||
</Actions>
|
||||
</Row>
|
||||
{restartResult && (
|
||||
<ResultBanner $success={restartResult.success}>
|
||||
재시작: {restartResult.success ? '✅ 성공' : `❌ ${restartResult.error}`}
|
||||
{restartResult.output && `\n${restartResult.output}`}
|
||||
</ResultBanner>
|
||||
)}
|
||||
{resetResult && (
|
||||
<ResultBanner $success={resetResult.success}>
|
||||
리셋: {resetResult.success ? '✅ 성공' : `❌ ${resetResult.error}`}
|
||||
</ResultBanner>
|
||||
)}
|
||||
{restartResult && <ResultBanner $success={restartResult.success}>재시작: {restartResult.success ? '✅ 성공' : `❌ ${restartResult.error}`}{restartResult.output && `\n${restartResult.output}`}</ResultBanner>}
|
||||
{resetResult && <ResultBanner $success={resetResult.success}>리셋: {resetResult.success ? '✅ 성공' : `❌ ${resetResult.error}`}</ResultBanner>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Table>
|
||||
|
||||
{modal && (
|
||||
<ConfirmModal
|
||||
title={modal.action === 'restart' ? '게이트웨이 재시작' : '세션 리셋'}
|
||||
message={`${SISTER_DISPLAY_NAMES[modal.name] ?? modal.name}의 ${modal.action === 'restart' ? 'OpenClaw Gateway를 재시작' : '메인 세션을 초기화'}하시겠어요?`}
|
||||
confirmLabel={modal.action === 'restart' ? '재시작' : '리셋'}
|
||||
danger={modal.action === 'reset'}
|
||||
onConfirm={() => doAction(modal.action, modal.name)}
|
||||
onCancel={() => setModal(null)}
|
||||
/>
|
||||
)}
|
||||
{modal && <ConfirmModal title={modal.action === 'restart' ? '게이트웨이 재시작' : '세션 리셋'} message={`${SISTER_DISPLAY_NAMES[modal.name] ?? modal.name}의 ${modal.action === 'restart' ? 'OpenClaw Gateway를 재시작' : '메인 세션을 초기화'}하시겠어요?`} confirmLabel={modal.action === 'restart' ? '재시작' : '리셋'} danger={modal.action === 'reset'} onConfirm={() => doAction(modal.action, modal.name)} onCancel={() => setModal(null)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -320,6 +320,7 @@ export default function ProjectDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [project, setProject] = useState<any>(null);
|
||||
const [tasks, setTasks] = useState<any[]>([]);
|
||||
const [history, setHistory] = useState<any[]>([]);
|
||||
const [commits, setCommits] = useState<any[]>([]);
|
||||
const [branches, setBranches] = useState<any[]>([]);
|
||||
const [pulls, setPulls] = useState<any[]>([]);
|
||||
@@ -332,9 +333,11 @@ export default function ProjectDetailPage() {
|
||||
Promise.allSettled([
|
||||
fetch(`${API_URL}/api/projects/${id}`),
|
||||
fetch(`${API_URL}/api/projects/${id}/tasks`),
|
||||
]).then(([pRes, tRes]) => {
|
||||
fetch(`${API_URL}/api/projects/${id}/history`),
|
||||
]).then(([pRes, tRes, hRes]) => {
|
||||
if (pRes.status === 'fulfilled' && pRes.value.ok) pRes.value.json().then(setProject);
|
||||
if (tRes.status === 'fulfilled' && tRes.value.ok) tRes.value.json().then(setTasks);
|
||||
if (hRes.status === 'fulfilled' && hRes.value.ok) hRes.value.json().then(setHistory);
|
||||
}).finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
@@ -429,7 +432,16 @@ export default function ProjectDetailPage() {
|
||||
</PhaseItem>
|
||||
);
|
||||
})}
|
||||
{tasks.length === 0 && <PhaseItem><PhaseMeta>PENDING</PhaseMeta><PhaseBox><PhaseName>Sprint 없음</PhaseName></PhaseBox></PhaseItem>}
|
||||
{history.length > 0 && history.map((entry: any) => (
|
||||
<PhaseItem key={entry.label}>
|
||||
<PhaseMeta>{entry.kind === 'hotfix' ? 'HOTFIX' : 'TIMELINE'}</PhaseMeta>
|
||||
<PhaseBox $active={false}>
|
||||
<PhaseName>{entry.label}</PhaseName>
|
||||
<PhaseDesc>{entry.kind === 'hotfix' ? '보정/수정 이력' : 'Sprint 진행 이력'}</PhaseDesc>
|
||||
</PhaseBox>
|
||||
</PhaseItem>
|
||||
))}
|
||||
{tasks.length === 0 && history.length === 0 && <PhaseItem><PhaseMeta>PENDING</PhaseMeta><PhaseBox><PhaseName>Sprint 없음</PhaseName></PhaseBox></PhaseItem>}
|
||||
</PhaseTimeline>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -147,7 +147,7 @@ export default function SisterDetailPage() {
|
||||
</MetaPanel>
|
||||
<div>
|
||||
<TabBar>
|
||||
{[{ id: 'overview', label: '개요' }, { id: 'sessions', label: '세션' }, { id: 'activity', label: '활동' }, { id: 'harness', label: '하네스' }, { id: 'logs', label: '로그' }, { id: 'config', label: '설정' }].map((t) => <TabBtn key={t.id} $active={tab === t.id} onClick={() => setTab(t.id)}>{t.label}</TabBtn>)}
|
||||
{[{ id: 'overview', label: '개요' }, { id: 'sessions', label: '세션' }, { id: 'activity', label: '활동' }, { id: 'harness', label: '하네스' }, { id: 'logs', label: '로그' }].map((t) => <TabBtn key={t.id} $active={tab === t.id} onClick={() => setTab(t.id)}>{t.label}</TabBtn>)}
|
||||
</TabBar>
|
||||
|
||||
{tab === 'overview' && <Timeline>{activity.slice(0, 8).map((item) => <TimelineItem key={item.id}><TimeStamp>{new Date(item.createdAt).toLocaleString('ko-KR', { hour12: false })}</TimeStamp><TimelineContent>{item.detail ?? item.action}</TimelineContent></TimelineItem>)}</Timeline>}
|
||||
@@ -155,7 +155,6 @@ export default function SisterDetailPage() {
|
||||
{tab === 'activity' && <Timeline>{activity.map((item) => <TimelineItem key={item.id}><TimeStamp>{new Date(item.createdAt).toLocaleString('ko-KR', { hour12: false })}</TimeStamp><TimelineContent>{item.detail ?? item.action}</TimelineContent></TimelineItem>)}</Timeline>}
|
||||
{tab === 'harness' && <><Toolbar><Select value={selectedFile} onChange={(e) => setSelectedFile(e.target.value)}>{FILES.map((f) => <option key={f} value={f}>{f}</option>)}</Select><Btn onClick={() => { setContent(saved); setSaveResult(null); }}>되돌리기</Btn><Btn onClick={handleSave}>저장</Btn></Toolbar>{saveResult && <ResultMsg $success={saveResult.success}>{saveResult.msg}</ResultMsg>}<CodeEditor value={content} onChange={setContent} /></>}
|
||||
{tab === 'logs' && <><Toolbar><span style={{ color: 'var(--text-secondary)', fontSize: '12px' }}>최근</span><LinesInput type="number" value={lines} onChange={(e) => setLines(parseInt(e.target.value, 10) || 100)} min={10} max={500} /><span style={{ color: 'var(--text-secondary)', fontSize: '12px' }}>줄</span><Btn onClick={fetchLogs}>{logLoading ? '로딩 중...' : '로그 가져오기'}</Btn></Toolbar><LogTerminal lines={logLines} /></>}
|
||||
{tab === 'config' && <><SectionTitle>SOUL.md / AGENTS.md</SectionTitle><ConfigPre>{configData?.raw || '(설정 파일 없음)'}</ConfigPre></>}
|
||||
</div>
|
||||
</DetailGrid>
|
||||
</>}
|
||||
|
||||
@@ -48,8 +48,17 @@ const GlobalStyle = createGlobalStyle`
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input, textarea, select {
|
||||
/* 기본적으로 관제 UI 텍스트 드래그 방지 */
|
||||
body {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
/* 편집/로그/입력/코드 영역은 선택 허용 */
|
||||
input, textarea, select, pre, code, [contenteditable="true"], .cm-editor, .cm-content {
|
||||
font-family: inherit;
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
|
||||
Reference in New Issue
Block a user