Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions app/c/[ws]/company-shell-context.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
'use client';

import { createContext, useContext } from 'react';

const CompanyShellContext = createContext(null);

export function CompanyShellProvider({ value, children }) {
return <CompanyShellContext.Provider value={value}>{children}</CompanyShellContext.Provider>;
}

export function useCompanyShell() {
return useContext(CompanyShellContext);
}
1,082 changes: 1,082 additions & 0 deletions app/c/[ws]/crew/[slug]/workspace-panel.jsx

Large diffs are not rendered by default.

87 changes: 86 additions & 1 deletion app/c/[ws]/layout.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,22 @@ import { usePathname, useRouter } from 'next/navigation';
import { StarMark, Icon, Avatar, Skeleton, Clock, ArgoSpinner, FeedbackModal, api } from '../../ui';
import { useLang, stageLabel } from '../../i18n';
import { useAppUpdate } from '../../use-app-update';
import WorkspacePanel from './crew/[slug]/workspace-panel';
import { CompanyShellProvider } from './company-shell-context';

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`);
const PANEL_STORAGE_KEY = 'argo:crew-side-panel:v1';

/** 크루 채팅 밖에서도 같은 Codex식 작업영역을 쓸 수 있게 회사 셸이 소유하는 패널. */
function GlobalWorkspacePanel({ ws, open, onClose, fileRequest, panelWidth, onWidthChange }) {
if (!open && !fileRequest) return null;
return (
<div className="global-workspace-panel" style={{ '--global-workspace-panel-width': `${panelWidth}px` }}>
<WorkspacePanel ws={ws} open={open} onClose={onClose} fileRequest={fileRequest} onWidthChange={onWidthChange} />
</div>
);
}

/** 백그라운드 작업 독 — 지금 도는 턴이 있으면 배지가 켜지고, 패널에서 진행·최근 작업을 본다. */
function TasksDock({ ws }) {
Expand Down Expand Up @@ -108,6 +121,49 @@ export default function CompanyShell({ children, params }) {
// 인증 상태 — 사이드바 하단에 로그인 이메일·로그아웃 노출(로컬 모드면 owner 표기 유지)
const [me, setMe] = useState(null);
const [fbOpen, setFbOpen] = useState(false); // 베타 피드백 모달
const [panelOpen, setPanelOpen] = useState(false);
const [globalPanelWidth, setGlobalPanelWidth] = useState(540);
const [panelMounted, setPanelMounted] = useState(false);
const [panelFileRequest, setPanelFileRequest] = useState(null);
const [panelHydrated, setPanelHydrated] = useState(false);
useEffect(() => {
try {
const saved = JSON.parse(localStorage.getItem(PANEL_STORAGE_KEY) || '{}');
if (typeof saved.open === 'boolean') setPanelOpen(saved.open);
} catch { /* 손상·프라이빗 모드 — 닫힌 상태로 시작 */ }
setPanelHydrated(true);
}, []);
useEffect(() => {
if (!panelHydrated) return;
try { localStorage.setItem(PANEL_STORAGE_KEY, JSON.stringify({ open: panelOpen })); } catch { /* 부가 상태 */ }
}, [panelHydrated, panelOpen]);
useEffect(() => { if (panelOpen) setPanelMounted(true); }, [panelOpen]);
useEffect(() => {
const onFile = (event) => {
const detail = event.detail;
if (!detail?.path) return;
setPanelFileRequest({ root: detail.root || 'company', path: detail.path, nonce: Date.now() });
setPanelMounted(true);
setPanelOpen(true);
};
window.addEventListener('argo:workspace-file', onFile);
return () => window.removeEventListener('argo:workspace-file', onFile);
}, []);
useEffect(() => {
const onOpen = () => { setPanelMounted(true); setPanelOpen(true); };
window.addEventListener('argo:workspace-panel-open', onOpen);
return () => window.removeEventListener('argo:workspace-panel-open', onOpen);
}, []);
useEffect(() => {
const onToggleShortcut = (event) => {
if (event.defaultPrevented || event.repeat || event.code !== 'KeyB'
|| !(event.ctrlKey || event.metaKey) || !event.altKey || event.shiftKey) return;
event.preventDefault();
setPanelOpen((open) => !open);
};
window.addEventListener('keydown', onToggleShortcut);
return () => window.removeEventListener('keydown', onToggleShortcut);
}, []);
useEffect(() => { api('/api/me').then(setMe).catch(() => {}); }, []);

// 상단 버전 뱃지 — 데스크톱 앱에서는 네이티브 설치 버전 + Tauri 업데이터가 단일 진실(설정 카드와 동일 소스).
Expand Down Expand Up @@ -236,6 +292,7 @@ export default function CompanyShell({ children, params }) {
});

return (
<CompanyShellProvider value={data}>
<div className="shell">
<aside className="side">
<Link href="/" className="nav-item" style={{ gap: 8, marginBottom: 4 }}>
Expand Down Expand Up @@ -436,9 +493,26 @@ export default function CompanyShell({ children, params }) {
<button onClick={() => setQ('')} style={{ color: 'var(--fg-3)', fontSize: 12, fontWeight: 700 }} aria-label={t('common.clear')}>✕</button>
)}
</label>
<button
type="button"
className={`btn btn-icon side-panel-toggle${panelOpen ? ' active' : ''}`}
aria-label={t('crew.panel.toggle')}
aria-controls="crew-side-panel"
aria-pressed={panelOpen}
title={`${t('crew.panel.toggle')} · ${t('crew.panel.shortcut')}`}
onClick={() => setPanelOpen((open) => !open)}
>
<Icon name="panel" size={16} />
</button>
</header>

<main className="content" style={{ width: '100%' }}>
<main
className={`content${panelOpen ? ' global-panel-open' : ''}`}
style={{
// 전역 패널은 fixed 오버레이지만 본문은 패널 폭만큼 실제로 줄여 겹치지 않게 한다.
width: panelOpen ? `calc(100% - ${globalPanelWidth}px)` : '100%',
}}
>
{data?.missing ? (
<div className="empty" style={{ marginTop: 40 }}>
{t('shell.notFound')} <Link href="/" style={{ color: 'var(--primary-strong)', fontWeight: 700 }}>{t('shell.backHome')}</Link>
Expand All @@ -447,6 +521,17 @@ export default function CompanyShell({ children, params }) {
</main>
</div>
{fbOpen && <FeedbackModal onClose={() => setFbOpen(false)} />}
{(panelOpen || panelMounted) && (
<GlobalWorkspacePanel
ws={ws}
open={panelOpen}
onClose={() => setPanelOpen(false)}
fileRequest={panelFileRequest}
panelWidth={globalPanelWidth}
onWidthChange={setGlobalPanelWidth}
/>
)}
</div>
</CompanyShellProvider>
);
}
30 changes: 24 additions & 6 deletions app/c/[ws]/page.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -590,6 +590,13 @@ function CrewEditModal({ ws, agent, teams, onClose, onSaved }) {
const [runners, setRunners] = useState(null);
useEffect(() => { api(`/api/runners?ws=${ws}`).then((d) => setRunners(d.runners)).catch(() => setRunners([])); }, [ws]);
const curRunner = runners?.find((r) => r.id === form.runner);
// 러너 미지정(자동)이어도 연결된 러너의 모델은 선택할 수 있어야 한다.
// 모델을 고르면 모델 소유 러너를 함께 지정해 서버의 runner/model 소속 검증을 만족시킨다.
const modelRunners = form.runner
? (curRunner ? [curRunner] : [])
: (runners ?? []).filter((r) => r.authed && r.models?.length);
const modelDisabled = runners === null
|| (form.runner ? !curRunner || !curRunner.authed : modelRunners.length === 0);
const runnerLabel = (r) => r.name + (r.authed ? '' : r.installed ? ` — ${t('runner.needLogin')}` : ` — ${t('runner.notInstalled')}`);

useEffect(() => {
Expand Down Expand Up @@ -661,13 +668,24 @@ function CrewEditModal({ ws, agent, teams, onClose, onSaved }) {
</label>
<label style={{ display: 'grid', gap: 4 }}>
<span className="microlabel">{t('deck.fieldModelHint')}</span>
{/* 현재 러너가 미연결(레거시)이면 모델 선택도 잠금 — 설정에서 연결 후 활성화 */}
<select value={form.model} onChange={(e) => setForm({ ...form, model: e.target.value })} style={field}
disabled={curRunner && !curRunner.authed}>
{/* 자동 러너는 연결된 모든 러너의 모델을 표시하고, 선택 시 해당 러너를 함께 고정한다. */}
<select value={form.model} onChange={(e) => {
const model = e.target.value;
const owner = !form.runner ? modelRunners.find((r) => r.models?.some((m) => m.id === model)) : null;
setForm({ ...form, runner: form.runner || owner?.id || '', model });
}} style={field} disabled={modelDisabled}>
{!form.model && <option value="" disabled>—</option>}{/* 레거시 미선택 크루 표시용 */}
{(curRunner?.models ?? []).map((m) => (
<option key={m.id} value={m.id}>{m.label}{m.gated ? ` — ${t('runner.gatedBadge')}` : m.free ? ` — ${t('runner.freeBadge')}` : ''}</option>
))}
{form.runner
? (curRunner?.models ?? []).map((m) => (
<option key={m.id} value={m.id}>{m.label}{m.gated ? ` — ${t('runner.gatedBadge')}` : m.free ? ` — ${t('runner.freeBadge')}` : ''}</option>
))
: modelRunners.map((r) => (
<optgroup key={r.id} label={r.name}>
{r.models.map((m) => (
<option key={`${r.id}:${m.id}`} value={m.id}>{m.label}{m.gated ? ` — ${t('runner.gatedBadge')}` : m.free ? ` — ${t('runner.freeBadge')}` : ''}</option>
))}
</optgroup>
))}
</select>
</label>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
Expand Down
2 changes: 1 addition & 1 deletion app/c/[ws]/settings/page.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -802,7 +802,7 @@ function WorkRootsCard({ ws }) {
}

return (
<div className="card" style={{ padding: 18, gridColumn: '1 / -1', display: 'grid', gap: 8 }}>
<div id="workroots" className="card" style={{ padding: 18, gridColumn: '1 / -1', display: 'grid', gap: 8, scrollMarginTop: 76 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span className="card-title">{t('settings.workroots.title')}</span>
<span className="chip">{t('settings.workroots.deviceLocal')}</span>
Expand Down
Loading