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 ( + + ); +} diff --git a/app/c/[ws]/split.mjs b/app/c/[ws]/split.mjs new file mode 100644 index 0000000..e8885dc --- /dev/null +++ b/app/c/[ws]/split.mjs @@ -0,0 +1,35 @@ +// 좌우 2분할(보조 패널) — 상태는 URL 쿼리 `?side=` 하나뿐이다. 전역 상태·컨텍스트 없음. +// spec: `crew:` | `doc:`. 순수 함수라 node 테스트가 그대로 임포트한다. + +const TYPES = new Set(['crew', 'doc']); + +/** `?side=` 값 → { type, key } | null(없음·잘못된 spec). key는 디코딩된 원문(한글 slug 그대로). */ +export function parseSide(str) { + if (typeof str !== 'string' || !str) return null; + const i = str.indexOf(':'); + if (i <= 0) return null; + const type = str.slice(0, i); + const key = str.slice(i + 1); + if (!TYPES.has(type) || !key) return null; + return { type, key }; +} + +/** { type, key } → `?side=` 값(인코딩 전 원문). parseSide(sideParam(x))가 x로 돌아온다. */ +export function sideParam(side) { + if (!side || !TYPES.has(side.type) || !side.key) return ''; + return `${side.type}:${side.key}`; +} + +/** href에 side 쿼리를 싣는다(기존 쿼리·해시 보존). sideStr이 비면 side 쿼리를 제거한다. */ +export function withSide(href, sideStr) { + const hashAt = href.indexOf('#'); + const hash = hashAt >= 0 ? href.slice(hashAt) : ''; + const base = hashAt >= 0 ? href.slice(0, hashAt) : href; + const qAt = base.indexOf('?'); + const path = qAt >= 0 ? base.slice(0, qAt) : base; + const params = new URLSearchParams(qAt >= 0 ? base.slice(qAt + 1) : ''); + if (sideStr) params.set('side', sideStr); + else params.delete('side'); + const q = params.toString(); + return `${path}${q ? `?${q}` : ''}${hash}`; +} diff --git a/app/c/[ws]/vault/page.jsx b/app/c/[ws]/vault/page.jsx index c5023da..2c30f3d 100644 --- a/app/c/[ws]/vault/page.jsx +++ b/app/c/[ws]/vault/page.jsx @@ -5,10 +5,11 @@ // 창은 최대 2개를 가로로 나란히(옆에 열기). 창 안에서는 한 번에 한 탭만 보인다(중첩 없음). // 페이지는 뷰포트 높이에 고정되고 스크롤은 각 패널 안에서만 일어난다. import { Suspense, use, useEffect, useState } from 'react'; -import { useSearchParams } from 'next/navigation'; +import { useRouter, useSearchParams } from 'next/navigation'; import { Icon, Markdown, Spinner, Skeleton, DangerModal, api, imeGuard, timeAgo, tsFromRel, resolveWikiRel, artifactDownload } from '../../../ui'; import { Graph2D } from '../graph2d'; // 2D 옵시디언식 — 3D 별자리(graphview)는 데크 위젯 전용 import { useLang } from '../../../i18n'; +import { sideParam, withSide } from '../split.mjs'; const GRAPH_TAB = { id: 'graph', kind: 'graph', root: null }; const MAX_TABS = 10; @@ -25,6 +26,9 @@ function Vault({ params }) { const { ws } = use(params); const { t, lang } = useLang(); const initialDoc = useSearchParams().get('doc'); + const router = useRouter(); + // 문서를 보조 패널(split-pane)로 — 주 화면(기억 페이지)은 그대로, ?side=doc:만 싣는다 + const sideOpen = (rel) => router.replace(withSide(`${window.location.pathname}${window.location.search}`, sideParam({ type: 'doc', key: rel }))); const [docs, setDocs] = useState(null); const [projects, setProjects] = useState([]); // 크루 산출물(vault/projects/) — 기억과 별도 축 const [q, setQ] = useState(''); @@ -197,13 +201,13 @@ function Vault({ params }) { {notes.length === 0 && !q && (docs.length + (projects?.length ?? 0)) > 0 && (

{t('vault.notesEmpty')}

)} - {sortDocs(notes).map((d) => )} + {sortDocs(notes).map((d) => )} - {sortDocs(journals).map((d) => )} + {sortDocs(journals).map((d) => )} - {sortDocs(convs).map((d) => )} + {sortDocs(convs).map((d) => )} {projectGroups.map(([name, files]) => ( @@ -218,7 +222,7 @@ function Vault({ params }) { {fmtSize(d.size)} ↓ ) : ( - + ))} ))} @@ -453,11 +457,18 @@ function Folder({ label, title, open: initialOpen = false, root = false, gen = 0 const fmtSize = (b) => (b >= 1048576 ? `${(b / 1048576).toFixed(1)}MB` : `${Math.max(1, Math.round((b ?? 0) / 1024))}KB`); /** 파일 행 — 이름만(옵시디언). 시각은 호버·활성 시만. ⌘/Alt 클릭 = 옆 창에 열기. 캐럿 자리만큼 들여써 폴더 이름과 정렬. */ -function FileRow({ d, active, onOpen, lang }) { +function FileRow({ d, active, onOpen, onSide, lang }) { + const { t } = useLang(); + // '옆에 열기'는 +
+ + +
); } diff --git a/app/globals.css b/app/globals.css index b18c47e..3b237d9 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1510,6 +1510,42 @@ select option, select optgroup { background: var(--card); color: var(--fg); } .content { padding: 26px 32px 88px; max-width: 1480px; margin: 0 auto; } +/* ─── 좌우 2분할(보조 패널) — ?side= 가 있으면 본문 옆에