diff --git a/app/c/[ws]/crew/[slug]/page.jsx b/app/c/[ws]/crew/[slug]/page.jsx
index fb73279..bb70046 100644
--- a/app/c/[ws]/crew/[slug]/page.jsx
+++ b/app/c/[ws]/crew/[slug]/page.jsx
@@ -9,6 +9,7 @@ import { Avatar, Icon, Markdown, ArgoSpinner, Spinner, Skeleton, DangerModal, Co
import { PICK_ORDER } from '../../../../runner-connect';
import { useLang, stageLabel } from '../../../../i18n';
import { CrewEditModal } from '../../crew-edit';
+import { sideParam, withSide } from '../../split.mjs';
/** 경과 시간 — 1:07 형태. 턴이 도는 동안 1초마다 갱신된다. */
const fmtElapsed = (ms) => `${Math.floor(ms / 60000)}:${String(Math.floor(ms / 1000) % 60).padStart(2, '0')}`;
@@ -189,7 +190,9 @@ function UserText({ text }) {
);
}
-export default function CrewChat({ params }) {
+// embedded = 보조 패널(split-pane) 안에 임베드. 주 화면 라우팅(topbar 포털·/memory 같은 이동 명령·해고 후 데크 이동)은
+// 하지 않는다 — 주 화면 URL은 패널이 바꾸면 안 된다. 초안·대기열 localStorage 키는 그대로(같은 크루 = 같은 초안).
+export default function CrewChat({ params, embedded = false, onClose }) {
const { ws, slug: slugParam } = use(params);
// 경로 조각은 **디코딩되지 않은 채** 온다(한글 이름 크루면 '%ED%81%B4…'). 예전엔 이 값을 그대로
// 목록과 대조해서(a.slug === slug) 영영 못 찾았고, 이름 자리에 퍼센트 문자열이 나왔다 — 해고 확인창이
@@ -313,7 +316,8 @@ export default function CrewChat({ params }) {
const [sel, setSel] = useState({ runner: '', model: '', effort: '' });
// 타이틀바 슬롯 — 크루 컨트롤(세션 상태·카드·새 대화)을 topbar에 포털로 꽂는다
const [slotEl, setSlotEl] = useState(null);
- useEffect(() => { setSlotEl(document.getElementById('argo-topbar-slot')); }, []);
+ useEffect(() => { if (!embedded) setSlotEl(document.getElementById('argo-topbar-slot')); }, [embedded]);
+ const [crewList, setCrewList] = useState([]); // '옆에 열기' 드롭다운용 — 회사 크루 목록
// 세션 적재 레일 — 새 대화로 넘긴 이전 대화들이 좌측에 쌓이고, 클릭으로 읽기 전용 열람
const [sessions, setSessions] = useState([]);
const [viewing, setViewing] = useState(null); // 보관 세션 id (null = 현재 대화)
@@ -458,6 +462,7 @@ export default function CrewChat({ params }) {
if (!alive) return;
const a = d.agents.find((a) => a.slug === slug) ?? { name: slug, role: '' };
setAgent(a);
+ setCrewList(d.agents ?? []);
setAliases(d.company?.aliases ?? []); // '/' 커맨더 사용자 별칭 — 회사 단위 공유
// '' = 미지정(자동) — 'claude'를 박으면 자동 크루가 화면·저장 모두 클로드 고정으로 둔갑(K2 오표시 계열)
setSel({ runner: a.runner || '', model: a.model || '', effort: a.effort || '' });
@@ -814,9 +819,12 @@ export default function CrewChat({ params }) {
{ id: 'new', aliases: ['new', '새대화'], label: t('chat.newChat'), run: () => newChat() },
{ id: 'card', aliases: ['card', '카드'], label: t('chat.card'), run: () => setCardOpen(true) },
{ id: 'panel', aliases: ['panel', '작업', 'files', '파일'], label: t('crew.panel.open'), run: () => setPanelOpen(true) },
- { id: 'memory', aliases: ['memory', '기억', 'vault'], label: t('nav.memory'), run: () => router.push(`/c/${ws}/vault`) },
- { id: 'room', aliases: ['room', '회의', '회의실'], label: t('nav.room'), run: () => router.push(`/c/${ws}/room`) },
- { id: 'deck', aliases: ['deck', '데크', 'home'], label: t('nav.deck'), run: () => router.push(`/c/${ws}`) },
+ // 주 화면 이동 명령은 패널(embedded)에서는 제공하지 않는다 — 패널이 주 화면 URL을 바꾸면 안 된다
+ ...(embedded ? [] : [
+ { id: 'memory', aliases: ['memory', '기억', 'vault'], label: t('nav.memory'), run: () => router.push(`/c/${ws}/vault`) },
+ { id: 'room', aliases: ['room', '회의', '회의실'], label: t('nav.room'), run: () => router.push(`/c/${ws}/room`) },
+ { id: 'deck', aliases: ['deck', '데크', 'home'], label: t('nav.deck'), run: () => router.push(`/c/${ws}`) },
+ ]),
];
const slashToken = !viewing ? input.match(/^\/(\S*)$/) : null;
const slashQ = slashToken ? slashToken[1].toLowerCase() : '';
@@ -856,10 +864,13 @@ export default function CrewChat({ params }) {
return (
// 세션레일(216, 좌측 원위치) + 채팅 컬럼(나머지 전체). 채팅은 .thread를 컬럼 전체폭으로 두고 안쪽 레인만 중앙정렬 →
// 스크롤바는 컬럼 우측 끝에 고정되고 메시지는 중앙 레인에 담긴다(가장 LLM다운 형태).
-
+ // embedded(보조 패널): 세션 레일 없이 채팅 컬럼만, 높이는 패널 본문을 가득(패널이 sticky·고정 높이).
+
{/* offset 100 = topbar56+상단26+하단여백18, marginBottom -70 = .content 하단 패딩(88) 상쇄로 body 스크롤 방지. 회의실·컨테스트와 동일(입력창 하향·대화영역 확대, 스레드만 내부 스크롤). */}
{/* 세션 레일 — 대화가 여기 적재된다. 무템플릿 grid는 트랙이 max-content로 자라 긴 제목이 폭을 밀어낸다 — minmax(0,1fr) 고정 */}
-
+ {!embedded &&
{t('chat.sessions.title')}{sessions.length ? ` · ${sessions.length + 1}` : ''}
@@ -919,9 +930,9 @@ export default function CrewChat({ params }) {
);
})}
{sessions.length === 0 && {t('chat.sessions.empty')}}
-
+
}
{ if ([...e.dataTransfer.types].includes('Files')) { e.preventDefault(); setDragOver(true); } }}
onDragLeave={(e) => { if (!e.currentTarget.contains(e.relatedTarget)) setDragOver(false); }}
onDrop={(e) => { e.preventDefault(); setDragOver(false); addFiles(e.dataTransfer.files); }}
@@ -939,9 +950,24 @@ export default function CrewChat({ params }) {
+
c.slug !== slug)}
+ onPick={(s) => router.replace(withSide(`${window.location.pathname}${window.location.search}`, sideParam({ type: 'crew', key: s })))} />
>,
slotEl,
)}
+ {/* 패널(embedded) — topbar 포털 대신 채팅 상단에 같은 컨트롤을 인라인으로(세션 상태·카드·새 대화만) */}
+ {embedded && (
+
+ {agent?.role}
+ {sessionRef.current ? (
+ {t('chat.sessionOngoing')}
+ ) : (
+ {t('chat.newSession')}
+ )}
+
+
+
+ )}
{/* 안쪽 레인만 중앙정렬 — .thread(스크롤 컨테이너)는 컬럼 전체폭이라 스크롤바가 우측 끝에 고정된다.
@@ -1333,7 +1359,7 @@ export default function CrewChat({ params }) {
sel={sel}
onRunnerChange={saveRunner}
onClose={() => setCardOpen(false)}
- onFired={() => { window.dispatchEvent(new Event('argo:refresh')); router.push(`/c/${ws}`); }}
+ onFired={() => { window.dispatchEvent(new Event('argo:refresh')); if (embedded) onClose?.(); else router.push(`/c/${ws}`); }}
/>
)}
@@ -1465,6 +1491,40 @@ function AliasModal({ onSave, onClose }) {
/** 채팅바 모델 메뉴 — 텍스트 버튼("Claude Code · Fable 5") 클릭 시 위로 뜨는 팝오버.
러너를 그룹 헤더로, 그 아래 모델(기본 포함)을 항목으로. 선택 즉시 저장(다음 턴부터 적용). */
+/** '옆에 열기 ▾' — 다른 크루 DM을 보조 패널로. ModelMenu와 같은 드롭다운 룩(card-float). 주 화면 topbar 전용. */
+function SideOpenMenu({ crews, onPick }) {
+ const { t } = useLang();
+ const [open, setOpen] = useState(false);
+ const boxRef = useRef(null);
+ useEffect(() => {
+ if (!open) return;
+ const away = (e) => { if (!boxRef.current?.contains(e.target)) setOpen(false); };
+ const esc = (e) => { if (e.key === 'Escape') setOpen(false); };
+ document.addEventListener('mousedown', away);
+ document.addEventListener('keydown', esc);
+ return () => { document.removeEventListener('mousedown', away); document.removeEventListener('keydown', esc); };
+ }, [open]);
+ return (
+
+
+ {open && (
+
+ {crews.length === 0 &&
{t('split.noCrew')}
}
+ {crews.map((c) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
function ModelMenu({ runners, sel, onChange, disabled }) {
const { t } = useLang();
const [open, setOpen] = useState(false);
diff --git a/app/c/[ws]/layout.jsx b/app/c/[ws]/layout.jsx
index 389e69e..a65ccd4 100644
--- a/app/c/[ws]/layout.jsx
+++ b/app/c/[ws]/layout.jsx
@@ -1,11 +1,13 @@
'use client';
// 회사 앱셸 — 라벨 사이드바(회사/크루 그룹 + 사용자 footer) + 헤더(타이틀·검색).
-import { use, useCallback, useEffect, useState } from 'react';
+import { Suspense, use, useCallback, useEffect, useState } from 'react';
import Link from 'next/link';
-import { usePathname, useRouter } from 'next/navigation';
+import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { StarMark, Icon, Avatar, Skeleton, Clock, ArgoSpinner, FeedbackModal, InputModal, api } from '../../ui';
import { useLang, stageLabel } from '../../i18n';
import { useAppUpdate } from '../../use-app-update';
+import { SplitPane } from './split-pane';
+import { parseSide, sideParam, withSide } from './split.mjs';
const fmtRun = (ms) => `${Math.floor(ms / 60000)}:${String(Math.floor(ms / 1000) % 60).padStart(2, '0')}`;
const fmtDur = (ms) => (ms == null ? '' : ms >= 60000 ? `${Math.floor(ms / 60000)}m ${Math.round((ms % 60000) / 1000)}s` : `${Math.round(ms / 1000)}s`);
@@ -93,11 +95,30 @@ function TasksDock({ ws }) {
);
}
-export default function CompanyShell({ children, params }) {
+// useSearchParams는 Suspense 경계 안에서 — 정적 프리렌더 bailout(next build 경고)을 막는다(vault 페이지와 동일 패턴)
+export default function CompanyShell(props) {
+ return (
+
+
+
+ );
+}
+
+function Shell({ children, params }) {
const { ws } = use(params);
const { t } = useLang();
const pathname = usePathname();
const router = useRouter();
+ // 좌우 2분할 보조 패널 — 상태는 ?side= 하나. 레이아웃 안의 내부 링크는 전부 withSide를 통과해
+ // 주 화면을 옮겨도 패널이 유지된다. 닫기 = side 쿼리 제거.
+ const searchParams = useSearchParams();
+ const sideStr = searchParams.get('side') || '';
+ const side = parseSide(sideStr);
+ const curQuery = searchParams.toString();
+ const hereWith = (sp) => withSide(`${pathname}${curQuery ? `?${curQuery}` : ''}`, sp);
+ const openSide = (spec) => router.replace(hereWith(sideParam(spec)));
+ const closeSide = () => router.replace(hereWith(''));
+ const L = (href) => withSide(href, sideStr); // 내부 링크 — 패널 유지
// 같은 페이지 재클릭 = 무동작 — 소프트 내비 전환(Link) 후에도 동일 URL 재이동으로 페이지 상태가 리셋되는 것을 막는다.
// 단 modifier/중클릭(새 탭·새 창)은 브라우저 기본 동작 보존 — 좌클릭만 가로챈다(분리 검수 지적 2026-07-24).
const navClick = (href) => (e) => {
@@ -253,28 +274,28 @@ export default function CompanyShell({ children, params }) {
{t('nav.company')}
-
+
{t('nav.deck')}
-
+
{t('nav.room')}
-
+
{t('nav.compete')}
-
+
{t('nav.memory')}
-
+
{t('nav.routines')}
-
+
{t('nav.activity')}
-
+
{t('nav.mail')}
-
+
{t('nav.market')}
@@ -324,7 +345,12 @@ export default function CompanyShell({ children, params }) {
setDragSlug(null); setDropSlug(null);
}}
style={{ position: 'relative', ...(dragSlug === a.slug ? { opacity: 0.45 } : {}), ...(dropSlug === a.slug && dragSlug ? { boxShadow: 'inset 0 2px 0 var(--primary)' } : {}) }}>
-
+
{
+ // cmd/ctrl+클릭 = 옆에 열기(새 탭 대신 — 크루 행 한정). 지금 보는 크루는 제외.
+ if ((e.metaKey || e.ctrlKey) && !e.shiftKey && e.button === 0) { e.preventDefault(); if (!active) openSide({ type: 'crew', key: a.slug }); return; }
+ navClick(href)(e);
+ }} className={`nav-item${active ? ' active' : ''}`} style={{ paddingTop: 6, paddingBottom: 6, paddingRight: 52 }}>
{a.slug in tgAgents && (
@@ -345,6 +371,13 @@ export default function CompanyShell({ children, params }) {
{/* 고정 토글 — pinned면 상시 골드, 아니면 행 hover 시 노출(.crew-row:hover .crew-pin). preventDefault로 링크 이동 차단.
활성 행 배경이 골드(--primary)라 골드 핀이 묻힌다 — 활성이면 온-골드 전경색(--primary-fg)으로 대비 확보(세션 레일과 동일 규칙, 실사용 신고 2026-07-21). */}
+ {/* 옆에 열기 — 보조 패널에 이 크루 DM. 지금 보는 크루는 비활성(주 화면과 같은 크루를 두 번 열 이유가 없다) */}
+
)}
+
{data?.missing ? (
@@ -462,6 +496,11 @@ export default function CompanyShell({ children, params }) {
) : children}
+ {side && !data?.missing && (
+
a.slug === side.key)?.name ?? side.key) : side.key.split('/').pop().replace(/\.md$/, '')} />
+ )}
+
{renameTeam != null && (
Math.max(W_MIN, Math.min(Math.round(window.innerWidth * 0.6), Math.round(w)));
+
+function DocPane({ ws, rel, side }) {
+ const { t } = useLang();
+ const [doc, setDoc] = useState(null); // null 로딩 | { content } | { error }
+ useEffect(() => {
+ let alive = true;
+ setDoc(null);
+ api(`/api/companies/${ws}/vault?rel=${encodeURIComponent(rel)}`)
+ .then((d) => { if (alive) setDoc({ content: d.content ?? '' }); })
+ .catch((e) => { if (alive) setDoc({ error: String(e?.message || e) }); });
+ return () => { alive = false; };
+ }, [ws, rel]);
+ return (
+
+
+ {rel}
+ {/* 기억 페이지는 ?doc=로 문서 선택을 받는다 — 패널은 유지한 채 주 화면만 이동 */}
+
+ {t('split.openInVault')}
+
+
+ {doc === null ? <>
>
+ : doc.error ?
{t('split.docMissing')}
+ :
}
+
+ );
+}
+
+/** side = { type:'crew'|'doc', key } (layout이 parseSide한 값), sideStr = 원문(링크 유지용). */
+export function SplitPane({ ws, side, sideStr, title, onClose }) {
+ const { t } = useLang();
+ const [w, setW] = useState(W_DEFAULT);
+ const [resizing, setResizing] = useState(false);
+ const wRef = useRef(W_DEFAULT);
+ useEffect(() => {
+ try { const v = Number(localStorage.getItem(W_KEY)); if (v) { wRef.current = clampW(v); setW(wRef.current); } } catch { /* 저장 불가 — 기본 폭 */ }
+ }, []);
+ useEffect(() => {
+ if (!resizing) return;
+ // 패널은 뷰포트 우측 끝에 붙어 있다 — 폭 = 뷰포트 우측 가장자리 − 커서 x
+ const onMove = (e) => { wRef.current = clampW(window.innerWidth - e.clientX); setW(wRef.current); };
+ const onUp = () => {
+ setResizing(false);
+ try { localStorage.setItem(W_KEY, String(wRef.current)); } catch { /* 무시 */ }
+ };
+ document.body.classList.add('split-resizing');
+ window.addEventListener('mousemove', onMove);
+ window.addEventListener('mouseup', onUp);
+ return () => {
+ document.body.classList.remove('split-resizing');
+ window.removeEventListener('mousemove', onMove);
+ window.removeEventListener('mouseup', onUp);
+ };
+ }, [resizing]);
+
+ // use(params)는 캐시된 프라미스를 기대한다 — 렌더마다 새 프라미스를 주면 매번 서스펜드한다
+ const crewParams = useMemo(() => Promise.resolve({ ws, slug: side.key }), [ws, side.key]);
+ const fullHref = side.type === 'crew'
+ ? `/c/${ws}/crew/${encodeURIComponent(side.key)}`
+ : `/c/${ws}/vault?doc=${encodeURIComponent(side.key)}`;
+
+ return (
+