- GitHub 연동이 설정되지 않았거나 접근 가능한 레포가 없습니다. 레포 이름을 직접 입력하세요.
+ 대표가 GitHub를 연결하면 목록이 보입니다. 지금은 레포 이름을 직접 입력하세요.
diff --git a/src/widgets/pm-room/PlanCard.tsx b/src/widgets/pm-room/PlanCard.tsx
index d0f4af6..5daba0a 100644
--- a/src/widgets/pm-room/PlanCard.tsx
+++ b/src/widgets/pm-room/PlanCard.tsx
@@ -226,6 +226,23 @@ function DraftBody({
{draft.rationale}
+ {/* 옛 초안에는 이 필드가 없다. PM은 통합 태스크를 만들지 않고, 대표가 G3 카드에서 이 항목을 확인한다 */}
+ {draft.integrationChecks && draft.integrationChecks.length > 0 && (
+
+
+ 통합 확인 항목 {draft.integrationChecks.length}개
+
+
+ {draft.integrationChecks.map((c) => (
+ {c}
+ ))}
+
+
+ 모든 태스크가 끝나면 대표의 승인 대기열에 통합 확인(G3) 카드로 올라옵니다.
+
+
+ )}
+
명세 {draft.specs.length}개
diff --git a/src/widgets/project-list/ProjectListView.tsx b/src/widgets/project-list/ProjectListView.tsx
index d34556c..4a4322c 100644
--- a/src/widgets/project-list/ProjectListView.tsx
+++ b/src/widgets/project-list/ProjectListView.tsx
@@ -126,12 +126,18 @@ export function ProjectListView() {
아직 배정된 프로젝트가 없습니다. 대표가 배정하면 여기에 나타납니다
-
- 배정되려면 에이전트가 연결돼 있어야 합니다.{" "}
-
- 에이전트 연결
-
-
+ {myAgent ? (
+
+ 에이전트 연결됨 · {myAgent.agentName}. 대표의 배정을 기다리는 중입니다.
+
+ ) : (
+
+ 배정되려면 에이전트가 연결돼 있어야 합니다.{" "}
+
+ 에이전트 연결
+
+
+ )}
>
)}
diff --git a/src/widgets/project-new/ProjectNewView.tsx b/src/widgets/project-new/ProjectNewView.tsx
index 38868ab..f4b8d92 100644
--- a/src/widgets/project-new/ProjectNewView.tsx
+++ b/src/widgets/project-new/ProjectNewView.tsx
@@ -10,7 +10,7 @@ import { cn } from "@/shared/lib/format";
import type { Level } from "@/shared/model";
import { LEVELS, LevelGates } from "@/entities/policy";
import { useCreateProject } from "@/entities/project";
-import { OwnershipBadge, useRepos } from "@/entities/repo";
+import { CreateGithubRepo, OwnershipBadge, useRepos } from "@/entities/repo";
import { useCurrentUser } from "@/entities/user";
type Step = "name" | "repos" | "level" | "pmBudget" | "extras";
@@ -240,6 +240,24 @@ const selectable = (r: Schemas["RepoListItem"]) => r.ownershipAssigned && r.acti
function RepoPicker({ orgId, onDone }: { orgId: string; onDone: (repos: { id: string; fullName: string }[]) => void }) {
const repos = useRepos(orgId);
const [picked, setPicked] = useState
>(new Set());
+ const [creating, setCreating] = useState(false);
+ // 이 화면은 대표 전용이라 "새 레포 만들기"를 항상 보여 준다. 만든 레포는 소유 역할이 있어 바로 고를 수 있다
+ const createRepo = creating ? (
+
+ setCreating(false)}
+ onCreated={(repo) => {
+ setPicked((prev) => new Set(prev).add(repo.id));
+ setCreating(false);
+ }}
+ />
+
+ ) : (
+ setCreating(true)}>
+ 새 레포 만들기
+
+ );
if (repos.isPending) {
return (
@@ -260,10 +278,14 @@ function RepoPicker({ orgId, onDone }: { orgId: string; onDone: (repos: { id: st
if (repos.data.length === 0) {
return (
- 연결된 레포가 없습니다. 먼저 조직에서 레포를 연결하세요.
-
- 조직으로
-
+
+ 연결된 레포가 없습니다. 새 레포를 만들거나{" "}
+
+ 조직에서 기존 레포를 연결
+
+ 하세요.
+
+ {createRepo}
);
}
@@ -315,14 +337,17 @@ function RepoPicker({ orgId, onDone }: { orgId: string; onDone: (repos: { id: st
))}
- onDone(repos.data.filter((r) => picked.has(r.id)))}
- >
- {picked.size > 0 ? `${picked.size}개 선택` : "선택"}
-
+ {createRepo}
+
+ onDone(repos.data.filter((r) => picked.has(r.id)))}
+ >
+ {picked.size > 0 ? `${picked.size}개 선택` : "선택"}
+
+
);
}
@@ -363,6 +388,7 @@ function Extras({
const create = useCreateProject(orgId);
const [budget, setBudget] = useState("");
const [deadline, setDeadline] = useState("");
+ const [questionRelay, setQuestionRelay] = useState(true);
const budgetUsd = budget.trim() === "" ? undefined : parseUsd(budget);
const submit = () =>
@@ -375,6 +401,8 @@ function Extras({
...(budgetUsd != null && { budgetUsd }),
// 는 YYYY-MM-DD 문자열을 준다. Date로 바꾸지 않는다(타임존)
...(deadline && { deadline }),
+ // 생략하면 켜짐
+ ...(!questionRelay && { questionRelay }),
},
{ onSuccess: (detail) => onCreated(detail.project.id) },
);
@@ -396,6 +424,21 @@ function Extras({
setDeadline(e.target.value)} />
+
+ setQuestionRelay(e.target.checked)}
+ />
+
+ 질문 중계
+
+ 끄면 에이전트가 다른 역할에게 묻지 않고 가정하고 진행합니다(대조군 실험 등). 시작 전까지 설정에서 바꿀 수
+ 있습니다.
+
+
+
{error && (
{error instanceof ApiError && error.code === "REPO_OWNERSHIP_NOT_SET" ? (
diff --git a/src/widgets/room/RoleRoomView.tsx b/src/widgets/room/RoleRoomView.tsx
new file mode 100644
index 0000000..4de2add
--- /dev/null
+++ b/src/widgets/room/RoleRoomView.tsx
@@ -0,0 +1,184 @@
+"use client";
+
+import Link from "next/link";
+import { useEffect, useRef } from "react";
+import { Loader2 } from "lucide-react";
+import { errorMessage, type Schemas } from "@/shared/api";
+import type { TeamRole } from "@/shared/model";
+import { Badge, Button, EmptyState } from "@/shared/ui";
+import { fmtDate, fmtDateTime, fmtTime, sameDay } from "@/shared/lib/format";
+import { StatusDot } from "@/entities/agent";
+import { useProject } from "@/entities/project";
+import { QuestionCard, useProjectQuestions } from "@/entities/question";
+import { ROLE_ROOM, ROOM_META, useRoomFeed, useRooms } from "@/entities/room";
+import { TEAM_ROLE_META } from "@/entities/task";
+import { useCurrentUser } from "@/entities/user";
+
+type RoomMessage = Schemas["RoomMessage"];
+
+/**
+ * 역할 룸(프로젝트 × 역할). 서버(PM)의 지시·판정, 실행 상태, 에이전트가 쓴 도구를 한 줄씩 보여 준다.
+ * 문장은 서버가 만든다 — 화면은 text를 그대로 찍는다. 팀원은 자기 역할 룸만 본다 (403 ROOM_NOT_VISIBLE)
+ */
+export function RoleRoomView({ projectId, role }: { projectId: string; role: TeamRole }) {
+ // AuthGate·AppShell 안쪽이므로 me와 프로젝트 상세가 있다
+ const me = useCurrentUser().me!;
+ const isRep = me.orgRole === "REPRESENTATIVE";
+ const { members } = useProject(projectId).data!;
+ const room = useRooms(projectId).data?.find((r) => r.role === role);
+ const feed = useRoomFeed(projectId, role);
+ const meta = ROOM_META[ROLE_ROOM[role].type];
+ // 이 역할이 답해야 하는 질문. 답할 수 있는 사람은 그 역할의 담당자와 대표다
+ const questions = useProjectQuestions(projectId).data?.filter((q) => q.targetRole === role) ?? [];
+ const canAnswer = isRep || members.some((m) => m.userId === me.userId && m.teamRole === role);
+
+ // API는 최신 줄부터 준다 — 대화처럼 오래된 줄이 위로 가게 뒤집는다
+ const messages = [...(feed.data ?? [])].reverse();
+ const newestId = feed.data?.[0]?.id;
+ const scrollRef = useRef
(null);
+ // 새 줄이 오면 맨 아래로. 이전 줄을 더 불러올 때는 newestId가 그대로라 움직이지 않는다
+ useEffect(() => {
+ scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
+ }, [newestId]);
+
+ return (
+
+
+
+
+
+ {feed.isPending ? (
+
+
+
+ ) : feed.isError ? (
+
+ ) : (
+ <>
+ {feed.hasNextPage && (
+
+ void feed.fetchNextPage()}
+ >
+ {feed.isFetchingNextPage ? "불러오는 중…" : "이전 줄 더 보기"}
+
+
+ )}
+ {messages.length === 0 ? (
+
+ ) : (
+
+ {messages.map((m, i) => (
+ 0}
+ />
+ ))}
+
+ )}
+ >
+ )}
+
+ {questions.length > 0 && (
+
+
+ {TEAM_ROLE_META[role].label}가 답할 질문 · {questions.length}
+
+ {questions.map((q) => (
+
+ ))}
+
+ )}
+
+
+
+ );
+}
+
+function FeedRow({
+ message: m,
+ role,
+ newDay,
+ hasQuestions,
+}: {
+ message: RoomMessage;
+ role: TeamRole;
+ newDay: boolean;
+ hasQuestions: boolean;
+}) {
+ return (
+ <>
+ {newDay && (
+
+ {fmtDate(m.ts)}
+
+ )}
+
+
+ {fmtTime(m.ts)}
+
+
+ {m.speaker === "pm" ? (
+ PM
+ ) : m.speaker === "nomos" ? (
+ NOMOS
+ ) : (
+ 에이전트
+ )}
+
+
+ {m.text}
+ {/* 질문 줄은 답할 쪽 룸에도 나온다 — 아래 답하기 카드로 잇는다 */}
+ {m.type === "QUESTION_ASKED" && hasQuestions && (
+ <>
+ {" "}
+
+ 답하기
+
+ >
+ )}
+
+
+ >
+ );
+}
diff --git a/src/widgets/room/index.ts b/src/widgets/room/index.ts
index 39bc404..f03d531 100644
--- a/src/widgets/room/index.ts
+++ b/src/widgets/room/index.ts
@@ -1 +1,2 @@
export { RoomView } from "./RoomView";
+export { RoleRoomView } from "./RoleRoomView";
diff --git a/src/widgets/settings/SettingsView.tsx b/src/widgets/settings/SettingsView.tsx
index 1730686..c5d35bd 100644
--- a/src/widgets/settings/SettingsView.tsx
+++ b/src/widgets/settings/SettingsView.tsx
@@ -6,7 +6,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { CheckCircle2, Loader2, Lock, Play } from "lucide-react";
import { errorDetails, errorMessage, type Schemas } from "@/shared/api";
import type { TeamRole } from "@/shared/model";
-import { Badge, Button, SectionTitle } from "@/shared/ui";
+import { Badge, Button, SectionTitle, Segmented } from "@/shared/ui";
import { cn, fmtDateTime } from "@/shared/lib/format";
import { agentKeys, useOrgAgents } from "@/entities/agent";
import { DECIDER_META, LEVELS, LevelGates, POLICY_TABLE } from "@/entities/policy";
@@ -15,10 +15,12 @@ import {
fmtUsd,
useAssignMember,
useProject,
+ useResendGithubInvite,
useStartProject,
useUnassignMember,
+ useUpdateProjectSettings,
} from "@/entities/project";
-import { TEAM_ROLE_META, TeamRoleBadge } from "@/entities/task";
+import { TEAM_ROLE_META, TeamRoleBadge, useTasks } from "@/entities/task";
import { useCurrentUser } from "@/entities/user";
const ROLES: TeamRole[] = ["FRONTEND", "BACKEND"];
@@ -84,8 +86,11 @@ export function SettingsView({ projectId }: { projectId: string }) {
agentsError={agents.error}
started={started}
isRep={isRep}
+ myUserId={me.userId}
/>
+
+
@@ -116,6 +121,7 @@ function MembersSection({
agentsError,
started,
isRep,
+ myUserId,
}: {
projectId: string;
orgId: string;
@@ -124,6 +130,7 @@ function MembersSection({
agentsError: Error | null;
started: boolean;
isRep: boolean;
+ myUserId: string;
}) {
const queryClient = useQueryClient();
const assign = useAssignMember(projectId);
@@ -170,6 +177,12 @@ function MembersSection({
{unassign.isPending && unassign.variables === member.agentId ? "해제 중…" : "해제"}
)}
+
>
) : editable ? (
= {
+ invited: { label: "초대 보냄", tone: "info" },
+ already_collaborator: { label: "접근 가능", tone: "success" },
+ skipped: { label: "보내지 못함", tone: "warn" },
+ failed: { label: "초대 실패", tone: "danger" },
+};
+
+/**
+ * 레포별 마지막 GitHub 협업자 초대 결과. 서버가 남긴 값이라 새로고침해도 그대로다.
+ * 이미 접근할 수 있는 레포(already_collaborator)는 할 일이 없어 보여 주지 않는다
+ */
+function GithubInvites({
+ projectId,
+ member,
+ isRep,
+ mine,
+}: {
+ projectId: string;
+ member: Member;
+ isRep: boolean;
+ mine: boolean;
+}) {
+ const resend = useResendGithubInvite(projectId);
+ const invites = member.githubInvites.filter((i) => i.status !== "already_collaborator");
+ if (invites.length === 0) return null;
+ const retryable = invites.some((i) => i.status === "skipped" || i.status === "failed");
+
+ return (
+
+
+ {invites.map((i) => (
+
+ {i.fullName}
+ {INVITE_META[i.status].label}
+ {i.status === "invited" ? (
+
+ {mine ? "GitHub 초대 메일을 수락하세요" : "멤버가 GitHub에서 수락해야 클론·push할 수 있습니다"}
+
+ ) : (
+ i.reason && {i.reason}
+ )}
+ {mine && i.status === "skipped" && (
+
+ GitHub 연결
+
+ )}
+
+ ))}
+
+ {/* 대표 전용 (시작 뒤에도 된다). 실패해도 200이라 결과는 다시 읽은 githubInvites로 본다 */}
+ {isRep && retryable && (
+
resend.mutate(member.agentId)}>
+ {resend.isPending ? "초대 보내는 중…" : "다시 초대"}
+
+ )}
+ {resend.isError &&
{errorMessage(resend.error)}
}
+
+ );
+}
+
+const RELAY_CHOICES = [
+ { value: true, label: "켜짐" },
+ { value: false, label: "꺼짐" },
+];
+
+function QuestionRelaySection({
+ projectId,
+ project,
+ editable,
+}: {
+ projectId: string;
+ project: Schemas["Project"];
+ editable: boolean;
+}) {
+ const update = useUpdateProjectSettings(projectId);
+
+ return (
+
+ questionRelay !== project.questionRelay && update.mutate({ questionRelay })}
+ disabled={update.isPending}
+ />
+ ) : (
+
+ {project.questionRelay ? "켜짐" : "꺼짐"}
+
+ )
+ }
+ >
+ 질문 중계
+
+
+ 켜면 에이전트가 다른 역할이 정해야 할 결정을 질문하고, 답이 올 때까지 기다립니다. 끄면 에이전트가 묻지 않고
+ 가정하고 진행하며, 가정한 내용은 인계 노트(GOTCHA)로 남깁니다(대조군 실험 등).
+
+ {!editable && 대표가 프로젝트 시작 전에만 바꿀 수 있습니다.
}
+ {update.isError && {errorMessage(update.error)}
}
+
+ );
+}
+
function AssignPicker({
role,
projectId,
@@ -279,6 +401,12 @@ function StartSection({
const start = useStartProject(projectId);
const [confirming, setConfirming] = useState(false);
const details = errorDetails(start.error);
+ // 미리 막아 두는 안내일 뿐 판정은 서버가 한다 (시작 시 400 details가 정본)
+ const tasks = useTasks(projectId).data;
+ const unassigned = ROLES.filter(
+ (role) => tasks?.some((t) => t.teamRole === role) && !members.some((m) => m.teamRole === role),
+ );
+ const noTasks = tasks?.length === 0;
return (
@@ -324,15 +452,37 @@ function StartSection({
) : (
-
{
- start.reset();
- setConfirming(true);
- }}
- >
- 프로젝트 시작
-
+ <>
+ {(unassigned.length > 0 || noTasks) && (
+
+ {noTasks ? (
+ <>
+ 태스크가 없습니다.{" "}
+
+ Room 3에서 PM 계획 받기
+
+ >
+ ) : (
+ <>
+ {unassigned.map((r) => TEAM_ROLE_META[r].label).join(", ")} 역할에 배정된 에이전트가 없습니다.{" "}
+
+ 역할 배정으로
+
+ >
+ )}
+
+ )}
+
0 || noTasks}
+ onClick={() => {
+ start.reset();
+ setConfirming(true);
+ }}
+ >
+ 프로젝트 시작
+
+ >
)}
>
)}
diff --git a/src/widgets/task-detail/TaskDetailView.tsx b/src/widgets/task-detail/TaskDetailView.tsx
index 9afa90e..eadefab 100644
--- a/src/widgets/task-detail/TaskDetailView.tsx
+++ b/src/widgets/task-detail/TaskDetailView.tsx
@@ -4,8 +4,8 @@ import Link from "next/link";
import { useState } from "react";
import { ArrowLeft, GitBranch, Loader2 } from "lucide-react";
import { errorMessage, type Schemas } from "@/shared/api";
-import { Badge, EmptyState, SectionTitle } from "@/shared/ui";
-import { cn, fmtDateTime } from "@/shared/lib/format";
+import { Badge, Button, EmptyState, Input, SectionTitle } from "@/shared/ui";
+import { cn, fmtDateTime, relTime } from "@/shared/lib/format";
import {
GateModeBadge,
VERIFICATION_ORDER,
@@ -15,7 +15,9 @@ import {
} from "@/entities/artifact";
import { NoteItem, useNotes } from "@/entities/note";
import { ProjectErrorView } from "@/entities/project";
-import { ApiTaskBadge, TeamRoleBadge, useTasks } from "@/entities/task";
+import { ROLE_ROOM } from "@/entities/room";
+import { ApiTaskBadge, BLOCKED_REASON, TeamRoleBadge, useResumeTask, useTasks } from "@/entities/task";
+import { useCurrentUser } from "@/entities/user";
// 결과가 늦게 오는 단계(V2·V4는 브릿지 보고). INTEGRATION은 아직 돌지 않으므로 빈자리로 보이지 않는다
const EXPECTED_STAGES = VERIFICATION_ORDER.filter((s) => s !== "INTEGRATION");
@@ -58,9 +60,12 @@ export function TaskDetailView({ projectId, taskId }: { projectId: string; taskI
{task.branchName}
)}
- {task.blockedReason && (
-
{task.blockedReason}
+ {(task.state === "CLAIMED" || task.state === "IN_PROGRESS") && task.claimedAt && (
+
+ 수령 {relTime(task.claimedAt)}
+
)}
+ {task.state === "BLOCKED" && task.blockedReason &&
}
@@ -73,6 +78,68 @@ export function TaskDetailView({ projectId, taskId }: { projectId: string; taskI
);
}
+/** BLOCKED는 이유마다 풀리는 방법이 다르다. QUESTION은 답하면 자동으로, AGENT_STOPPED는 대표가 재개해야 풀린다 */
+function Blocked({ projectId, task }: { projectId: string; task: Schemas["Task"] }) {
+ const isRep = useCurrentUser().me?.orgRole === "REPRESENTATIVE";
+ const resume = useResumeTask();
+ const [note, setNote] = useState("");
+ const reason = task.blockedReason!;
+ const roomHref = task.teamRole && `/p/${projectId}/rooms/${ROLE_ROOM[task.teamRole].slug}`;
+
+ return (
+
+
{BLOCKED_REASON[reason]}
+ {reason === "QUESTION" && (
+
+ 에이전트가 다른 역할에게 질문하고 태스크를 잠시 내려놓았습니다. 답이 오면 자동으로 다시 시작합니다 ·{" "}
+
+ 받은 편지함에서 질문 보기
+
+
+ )}
+ {reason === "AGENT_STOPPED" && (
+ <>
+
+ 에이전트가 제출하지 않고 끝났습니다(권한·환경 문제 또는 응답 끊김). 재시도 횟수는 오르지 않습니다.
+ {roomHref && (
+ <>
+ {" "}
+ 사유는{" "}
+
+ 룸 피드
+
+ 에 있습니다.
+ >
+ )}
+
+ {isRep ? (
+
+ setNote(e.target.value)}
+ disabled={resume.isPending}
+ placeholder="무엇을 해결했는지 (선택, 룸에 보입니다)"
+ aria-label="재개 메모"
+ className="h-8 min-w-0 flex-1 bg-white text-[13px] sm:max-w-[360px]"
+ />
+ resume.mutate({ taskId: task.id, note: note.trim() || undefined })}
+ >
+ {resume.isPending ? "재개하는 중…" : "재개"}
+
+
+ ) : (
+
대표가 원인을 해결한 뒤 재개합니다.
+ )}
+ {resume.isError &&
{errorMessage(resume.error)}
}
+ >
+ )}
+
+ );
+}
+
function Spinner() {
return (