feat: harden company resume, external sessions, and Office UI i18n
Improve suspend/resume identity continuity and external broker session handling, add Office UI internationalization with rebuilt assets, and expand company org configs. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -91,6 +91,19 @@ def _ensure_aiohttp() -> None:
|
||||
_pip_install(*missing)
|
||||
|
||||
|
||||
def _run_npm(npm: str, *args: str) -> None:
|
||||
"""Run npm in a way that also works when Windows resolves npm.cmd."""
|
||||
cmd = [npm, *args]
|
||||
if sys.platform == "win32":
|
||||
npm_path = Path(npm)
|
||||
npm_cmd = npm_path.with_suffix(".cmd")
|
||||
if npm_path.suffix.lower() in {".cmd", ".bat"}:
|
||||
cmd = [os.environ.get("COMSPEC", "cmd.exe"), "/c", npm, *args]
|
||||
elif npm_cmd.exists():
|
||||
cmd = [os.environ.get("COMSPEC", "cmd.exe"), "/c", str(npm_cmd), *args]
|
||||
subprocess.check_call(cmd, cwd=_FRONTEND_SRC)
|
||||
|
||||
|
||||
def _ensure_frontend() -> None:
|
||||
"""Build frontend if frontend_dist is missing, empty, or stale."""
|
||||
if not _frontend_needs_rebuild():
|
||||
@@ -114,11 +127,11 @@ def _ensure_frontend() -> None:
|
||||
node_modules = _FRONTEND_SRC / "node_modules"
|
||||
if not node_modules.is_dir():
|
||||
terminal_status("Installing npm dependencies")
|
||||
subprocess.check_call([npm, "install"], cwd=_FRONTEND_SRC)
|
||||
_run_npm(npm, "install")
|
||||
|
||||
# npm run build → outputs to ../frontend_dist via vite config
|
||||
terminal_status("Building frontend bundle")
|
||||
subprocess.check_call([npm, "run", "build"], cwd=_FRONTEND_SRC)
|
||||
_run_npm(npm, "run", "build")
|
||||
|
||||
if _FRONTEND_DIST.is_dir() and any(_FRONTEND_DIST.iterdir()):
|
||||
terminal_status("Frontend built successfully", kind="success")
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -5,12 +5,11 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="data:," />
|
||||
<title>OpenOPC Pixel Office</title>
|
||||
<script type="module" crossorigin src="./assets/index-CTK-c7_K.js"></script>
|
||||
<script type="module" crossorigin src="./assets/index-E3Xtv5nT.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="./assets/phaser-DFK5Ua9d.js">
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-DEvLDWDw.css">
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-B6-ikSHW.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import { extractSessionRecruitmentByRole, sessionChannelId } from './lib/session
|
||||
import { resolveCanonicalTurnId, terminalAssistantTurnId } from './lib/turnIdentity'
|
||||
import { unassignAgent } from './game/map/OfficeStore'
|
||||
import type { AgentAnimStatus, EmployeeAssignment, KanbanPhase, KanbanTask, RoleAggregatedStatus, RoleWorkItemSummary, Session, TaskPreferredAgent } from './types/kanban'
|
||||
import { useI18n } from './i18n'
|
||||
|
||||
function readOutdoorOverrideUi(): 'auto' | 'day' | 'night' {
|
||||
try {
|
||||
@@ -444,6 +445,7 @@ function MaybeExecutionPanel({ taskId, sessions, agents, onClose }: {
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { locale, setLocale, t, translateMaybe } = useI18n()
|
||||
const bridgeRef = useRef(new GameBridge())
|
||||
useMemo(() => registerTestRunner(bridgeRef.current), [])
|
||||
const clientRef = useRef<VisualSocketClient | null>(null)
|
||||
@@ -2347,26 +2349,47 @@ export default function App() {
|
||||
<div className="topbar-center">
|
||||
<div className="page-nav">
|
||||
<button className={`page-nav-btn${activePage === 'workspace' ? ' active' : ''}`} onClick={() => setActivePage('workspace')}>
|
||||
Workspace
|
||||
{t('app.page.workspace')}
|
||||
{(() => {
|
||||
const total = chatStore.channels.reduce((sum, ch) => sum + chatStore.getUnreadCount(ch.id), 0)
|
||||
return total > 0 ? <span className="nav-unread-badge">{total > 99 ? '99+' : total}</span> : null
|
||||
})()}
|
||||
</button>
|
||||
<button className={`page-nav-btn${activePage === 'office' ? ' active' : ''}`} onClick={() => setActivePage('office')}>Office</button>
|
||||
<button className={`page-nav-btn${activePage === 'org' ? ' active' : ''}`} onClick={() => setActivePage('org')}>Org</button>
|
||||
<button className={`page-nav-btn${activePage === 'office' ? ' active' : ''}`} onClick={() => setActivePage('office')}>{t('app.page.office')}</button>
|
||||
<button className={`page-nav-btn${activePage === 'org' ? ' active' : ''}`} onClick={() => setActivePage('org')}>{t('app.page.org')}</button>
|
||||
</div>
|
||||
<div className="stat-chips">
|
||||
<span className="stat-chip"><b>{metrics.totalAgents}</b> agents</span>
|
||||
<span className="stat-chip"><b>{metrics.totalSkills}</b> skills</span>
|
||||
<span className="stat-chip"><b>{boardStore.getOpenTaskCount()}</b> tasks</span>
|
||||
<span className="stat-chip"><b>{metrics.totalAgents}</b> {t('app.metric.agents')}</span>
|
||||
<span className="stat-chip"><b>{metrics.totalSkills}</b> {t('app.metric.skills')}</span>
|
||||
<span className="stat-chip"><b>{boardStore.getOpenTaskCount()}</b> {t('app.metric.tasks')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="topbar-right">
|
||||
<div className="language-toggle" role="group" aria-label={t('language.label')} title={t('language.label')}>
|
||||
<button
|
||||
type="button"
|
||||
data-locale="en"
|
||||
aria-pressed={locale === 'en'}
|
||||
className={`language-toggle-btn${locale === 'en' ? ' active' : ''}`}
|
||||
onClick={() => setLocale('en')}
|
||||
>
|
||||
{t('language.english')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-locale="zh-CN"
|
||||
aria-pressed={locale === 'zh-CN'}
|
||||
className={`language-toggle-btn${locale === 'zh-CN' ? ' active' : ''}`}
|
||||
onClick={() => setLocale('zh-CN')}
|
||||
>
|
||||
{t('language.chinese')}
|
||||
</button>
|
||||
</div>
|
||||
<select
|
||||
className="theme-select"
|
||||
value={outdoorOverride}
|
||||
title="Outdoor lighting override"
|
||||
title={t('outdoor.title')}
|
||||
aria-label={t('outdoor.title')}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value as 'auto' | 'day' | 'night'
|
||||
setOutdoorOverride(v)
|
||||
@@ -2384,20 +2407,20 @@ export default function App() {
|
||||
bridgeRef.current.syncOutdoorLighting()
|
||||
}}
|
||||
>
|
||||
<option value="auto">Outdoor auto</option>
|
||||
<option value="day">Outdoor day</option>
|
||||
<option value="night">Outdoor night</option>
|
||||
<option value="auto">{t('outdoor.auto')}</option>
|
||||
<option value="day">{t('outdoor.day')}</option>
|
||||
<option value="night">{t('outdoor.night')}</option>
|
||||
</select>
|
||||
<select className="theme-select" value={theme} onChange={(e) => setTheme(e.target.value as ThemeName)}>
|
||||
<option value="midnight">Midnight</option>
|
||||
<option value="neon">Neon</option>
|
||||
<option value="paper">Paper</option>
|
||||
<option value="retro">Retro</option>
|
||||
<option value="terminal">Terminal</option>
|
||||
<option value="cozy">Cozy</option>
|
||||
<option value="openopc">OpenOPC</option>
|
||||
<option value="midnight">{t('theme.midnight')}</option>
|
||||
<option value="neon">{t('theme.neon')}</option>
|
||||
<option value="paper">{t('theme.paper')}</option>
|
||||
<option value="retro">{t('theme.retro')}</option>
|
||||
<option value="terminal">{t('theme.terminal')}</option>
|
||||
<option value="cozy">{t('theme.cozy')}</option>
|
||||
<option value="openopc">{t('theme.openopc')}</option>
|
||||
</select>
|
||||
<button className={`icon-btn ${showDevTools ? 'active' : ''}`} onClick={() => setShowDevTools((v) => !v)} title="Developer Tools">
|
||||
<button className={`icon-btn ${showDevTools ? 'active' : ''}`} onClick={() => setShowDevTools((v) => !v)} title={t('dev.tools')} aria-label={t('dev.tools')}>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M5.5 2L2 5.5 5.5 9" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/><path d="M10.5 7L14 10.5 10.5 14" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -2552,14 +2575,14 @@ export default function App() {
|
||||
{/* Phaser Game Canvas */}
|
||||
<section className="canvas-wrap">
|
||||
<PhaserGame bridge={bridgeRef.current} active={activePage === 'office'} />
|
||||
<button className="canvas-float-btn" onClick={() => setShowSubagents((v) => !v)} title={showSubagents ? 'Hide sub-agents' : 'Show sub-agents'}>
|
||||
<button className="canvas-float-btn" onClick={() => setShowSubagents((v) => !v)} title={showSubagents ? t('office.hideSubagents') : t('office.showSubagents')}>
|
||||
{showSubagents ? '👥' : '👤'}
|
||||
</button>
|
||||
<button
|
||||
className="sidebar-collapse-btn"
|
||||
onClick={toggleSidebar}
|
||||
title={sidebarCollapsed ? 'Show side panel' : 'Hide side panel'}
|
||||
aria-label={sidebarCollapsed ? 'Show side panel' : 'Hide side panel'}
|
||||
title={sidebarCollapsed ? t('office.showSidePanel') : t('office.hideSidePanel')}
|
||||
aria-label={sidebarCollapsed ? t('office.showSidePanel') : t('office.hideSidePanel')}
|
||||
>
|
||||
<span className="collapse-glyph">{sidebarCollapsed ? '❮' : '❯'}</span>
|
||||
</button>
|
||||
@@ -2574,13 +2597,13 @@ export default function App() {
|
||||
<div className="mode-info-bar">
|
||||
<span className="mode-badge">{globalExecMode === 'company' ? `${globalExecMode}/${globalCompanyProfile}` : globalModeLabel}</span>
|
||||
{isOrgMode ? (
|
||||
<span className="mode-hint">Manage your team in the <b>Org</b> tab</span>
|
||||
<span className="mode-hint">{t('office.modeHint.org')}</span>
|
||||
) : (
|
||||
<span className="mode-hint">Switch to <b>Org</b> mode to create or manage agents</span>
|
||||
<span className="mode-hint">{t('office.modeHint.switch')}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="section-label">Offices <span className="count-badge">{offices.length}</span></div>
|
||||
<div className="section-label">{t('office.offices')} <span className="count-badge">{offices.length}</span></div>
|
||||
<div className="office-cards">
|
||||
{offices.map((office) => {
|
||||
const deskCount = getOfficeDeskSeats(office.id).length
|
||||
@@ -2602,14 +2625,14 @@ export default function App() {
|
||||
) : (
|
||||
<>
|
||||
<span className="office-name">{office.name}</span>
|
||||
<button className="office-edit-btn" title="Rename" onClick={(e) => { e.stopPropagation(); setEditingOfficeName(office.id); setOfficeNameDraft(office.name) }}>✎</button>
|
||||
<button className="office-edit-btn" title={t('office.rename')} onClick={(e) => { e.stopPropagation(); setEditingOfficeName(office.id); setOfficeNameDraft(office.name) }}>✎</button>
|
||||
</>
|
||||
)}
|
||||
<span className="office-capacity">{assignedCards.length}/{deskCount}</span>
|
||||
</div>
|
||||
<div className="office-agents">
|
||||
{assignedCards.map(c => (
|
||||
<span key={c.id} className="office-agent-chip" title={`${c.displayName} — ${c.seatId ?? 'no seat'}`} onClick={(e) => { e.stopPropagation(); selectAgent(c.id) }}>
|
||||
<span key={c.id} className="office-agent-chip" title={`${c.displayName} — ${c.seatId ?? t('office.noSeat')}`} onClick={(e) => { e.stopPropagation(); selectAgent(c.id) }}>
|
||||
{c.displayName.slice(0, 8)}
|
||||
</span>
|
||||
))}
|
||||
@@ -2620,7 +2643,7 @@ export default function App() {
|
||||
onClick={e => e.stopPropagation()}
|
||||
onChange={e => { if (e.target.value) handleAssignAgent(office.id, e.target.value) }}
|
||||
>
|
||||
<option value="">+ Move here</option>
|
||||
<option value="">{t('office.moveHere')}</option>
|
||||
{otherAgents.map(a => (
|
||||
<option key={a.id} value={a.id}>{a.displayName} ({offices.find(o => o.id === (cards.find(cc => cc.id === a.id)?.officeId))?.name ?? '?'})</option>
|
||||
))}
|
||||
@@ -2632,7 +2655,7 @@ export default function App() {
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="section-label">Active Agents <span className="count-badge">{swarmAgents.length}</span></div>
|
||||
<div className="section-label">{t('office.activeAgents')} <span className="count-badge">{swarmAgents.length}</span></div>
|
||||
<div className="agent-list">
|
||||
{swarmAgents.map((agent) => (
|
||||
<div key={agent.agent_id} className={`agent-row ${selectedAgentId === agent.agent_id ? 'selected' : ''}`}>
|
||||
@@ -2640,7 +2663,7 @@ export default function App() {
|
||||
<span className={`dot ${agent.status}`} />
|
||||
<div className="agent-info">
|
||||
<span className="agent-name">{agent.name}</span>
|
||||
<span className="agent-spec">{agent.specialties.slice(0, 2).join(' · ') || 'general'}</span>
|
||||
<span className="agent-spec">{agent.specialties.slice(0, 2).join(' · ') || t('common.general')}</span>
|
||||
</div>
|
||||
</button>
|
||||
{isOrgMode && (
|
||||
@@ -2648,27 +2671,27 @@ export default function App() {
|
||||
? <span className="agent-del" style={{ pointerEvents: 'none' }}><span className="spinner-inline" /></span>
|
||||
: confirmDeleteId === agent.agent_id
|
||||
? <span className="del-confirm">
|
||||
<span className="del-confirm-label">Delete?</span>
|
||||
<button className="del-confirm-yes" onClick={() => { setDeletingAgentId(agent.agent_id); setConfirmDeleteId(null); clientRef.current?.deleteAgent(agent.agent_id) }}>Yes</button>
|
||||
<button className="del-confirm-no" onClick={() => setConfirmDeleteId(null)}>No</button>
|
||||
<span className="del-confirm-label">{t('office.deleteQuestion')}</span>
|
||||
<button className="del-confirm-yes" onClick={() => { setDeletingAgentId(agent.agent_id); setConfirmDeleteId(null); clientRef.current?.deleteAgent(agent.agent_id) }}>{t('common.yes')}</button>
|
||||
<button className="del-confirm-no" onClick={() => setConfirmDeleteId(null)}>{t('common.no')}</button>
|
||||
</span>
|
||||
: <button className="agent-del" title={`Remove ${agent.name}`} onClick={() => setConfirmDeleteId(agent.agent_id)}>×</button>
|
||||
: <button className="agent-del" title={t('office.removeAgent', { name: agent.name })} onClick={() => setConfirmDeleteId(agent.agent_id)}>×</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{swarmAgents.length === 0 && (
|
||||
<div className="empty-state">No agents yet — click a template above to spawn one.</div>
|
||||
<div className="empty-state">{t('office.emptyAgents')}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedCard && (
|
||||
<div className="agent-detail">
|
||||
<div className="agent-detail-name">{selectedCard.displayName}</div>
|
||||
<div className="agent-detail-row"><span className="detail-label">State</span><span className="detail-value">{selectedCard.state}</span></div>
|
||||
<div className="agent-detail-row"><span className="detail-label">Tool</span><span className="detail-value">{selectedCard.currentTool ?? '—'}</span></div>
|
||||
<div className="agent-detail-row"><span className="detail-label">Task</span><span className="detail-value">{selectedCard.taskSummary ?? '—'}</span></div>
|
||||
<div className="agent-detail">
|
||||
<div className="agent-detail-name">{selectedCard.displayName}</div>
|
||||
<div className="agent-detail-row"><span className="detail-label">{t('common.state')}</span><span className="detail-value">{translateMaybe('agent.status', selectedCard.state) || selectedCard.state}</span></div>
|
||||
<div className="agent-detail-row"><span className="detail-label">{t('common.tool')}</span><span className="detail-value">{selectedCard.currentTool ?? '—'}</span></div>
|
||||
<div className="agent-detail-row"><span className="detail-label">{t('common.task')}</span><span className="detail-value">{selectedCard.taskSummary ?? '—'}</span></div>
|
||||
<div className="agent-detail-row">
|
||||
<span className="detail-label">Office</span>
|
||||
<span className="detail-label">{t('app.page.office')}</span>
|
||||
<select
|
||||
className="detail-select"
|
||||
value={selectedCard.officeId}
|
||||
@@ -2679,7 +2702,7 @@ export default function App() {
|
||||
</select>
|
||||
</div>
|
||||
<div className="agent-detail-row">
|
||||
<span className="detail-label">Seat</span>
|
||||
<span className="detail-label">{t('common.seat')}</span>
|
||||
<select
|
||||
className="detail-select"
|
||||
value={selectedCard.seatId ?? ''}
|
||||
@@ -2704,8 +2727,8 @@ export default function App() {
|
||||
{cards.length > swarmAgents.length && (
|
||||
<>
|
||||
<div className="section-label">
|
||||
Characters
|
||||
<button className="inline-btn" onClick={() => setShowSubagents((v) => !v)}>{showSubagents ? 'hide sub' : 'show sub'}</button>
|
||||
{t('office.characters')}
|
||||
<button className="inline-btn" onClick={() => setShowSubagents((v) => !v)}>{showSubagents ? t('office.hideSub') : t('office.showSub')}</button>
|
||||
</div>
|
||||
<div className="agent-list">
|
||||
{cards.filter((c) => !swarmAgents.some((a) => a.agent_id === c.id)).map((card) => (
|
||||
@@ -2727,28 +2750,29 @@ export default function App() {
|
||||
{showDevTools && (
|
||||
<div className="dev-overlay">
|
||||
<div className="dev-header">
|
||||
<span className="dev-title">Developer Tools</span>
|
||||
<span className="dev-title">{t('dev.tools')}</span>
|
||||
<button className="icon-btn" onClick={() => setShowDevTools(false)}>✕</button>
|
||||
</div>
|
||||
<div className="dev-group">
|
||||
<div className="dev-label">Connection</div>
|
||||
<div className="dev-label">{t('dev.connection')}</div>
|
||||
<div className="input-row">
|
||||
<input value={wsUrlInput} onChange={(e) => setWsUrlInput(e.target.value)} placeholder="ws://..." />
|
||||
<button className="send-btn" onClick={applyWsUrl}>↩</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="dev-group">
|
||||
<div className="dev-label">Evolution Pipeline</div>
|
||||
<div className="dev-label">{t('dev.evolution')}</div>
|
||||
<div className="evo-pipeline">
|
||||
{(['Trace', 'Reflect', 'Synthesize', 'Practice', 'Lifecycle'] as const).map((phase, i) => {
|
||||
const key = phase.toLowerCase() as keyof typeof evolutionPhases
|
||||
const active = key in evolutionPhases ? evolutionPhases[key as 'trace' | 'reflect' | 'synthesize'] : false
|
||||
const phaseLabelKey = `dev.phase.${key}` as Parameters<typeof t>[0]
|
||||
return (
|
||||
<div key={phase} className="evo-phase-group">
|
||||
{i > 0 && <div className="evo-connector" />}
|
||||
<div className={`evo-node ${active ? 'active' : ''}`}>
|
||||
<div className="evo-dot" />
|
||||
<span className="evo-label">{phase}</span>
|
||||
<span className="evo-label">{t(phaseLabelKey)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -2765,7 +2789,7 @@ export default function App() {
|
||||
</div>
|
||||
<div className="dev-group">
|
||||
<div className="dev-label">
|
||||
Events
|
||||
{t('dev.events')}
|
||||
<select className="inline-select" value={eventTypeFilter} onChange={(e) => setEventTypeFilter(e.target.value)}>
|
||||
{eventTypes.map((type) => <option key={type} value={type}>{type}</option>)}
|
||||
</select>
|
||||
@@ -2783,7 +2807,7 @@ export default function App() {
|
||||
</div>
|
||||
{Object.keys(snapshot?.channels ?? {}).length > 0 && (
|
||||
<div className="dev-group">
|
||||
<div className="dev-label">Channels</div>
|
||||
<div className="dev-label">{t('dev.channels')}</div>
|
||||
{Object.entries(snapshot?.channels ?? {}).map(([name, info]) => (
|
||||
<div className="list-row" key={name}>
|
||||
<span>{name}</span>
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Session } from '../types/kanban'
|
||||
import { IconPlus, IconSearch, IconActivity, IconShield, IconTrash, IconWorkItem } from './SvgIcons'
|
||||
import { getSessionRuntimeStatus } from '../lib/sessionRuntime'
|
||||
import { deriveCompanyRuntimeDisplayStatus } from '../lib/workItemSessions'
|
||||
import { useI18n } from '../i18n'
|
||||
|
||||
interface SessionSidebarProps {
|
||||
sessions: Session[]
|
||||
@@ -35,12 +36,14 @@ function sessionDotClass(session: Session): string {
|
||||
return STATUS_DOT[displayStatus] ?? 'status-pending'
|
||||
}
|
||||
|
||||
function relativeTime(ts: number): string {
|
||||
type TFunction = ReturnType<typeof useI18n>['t']
|
||||
|
||||
function relativeTime(ts: number, t: TFunction): string {
|
||||
const diff = Date.now() - ts
|
||||
if (diff < 60_000) return 'just now'
|
||||
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m`
|
||||
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h`
|
||||
return `${Math.floor(diff / 86_400_000)}d`
|
||||
if (diff < 60_000) return t('session.justNow')
|
||||
if (diff < 3_600_000) return t('session.minutes', { count: Math.floor(diff / 60_000) })
|
||||
if (diff < 86_400_000) return t('session.hours', { count: Math.floor(diff / 3_600_000) })
|
||||
return t('session.days', { count: Math.floor(diff / 86_400_000) })
|
||||
}
|
||||
|
||||
function dateGroup(ts: number): string {
|
||||
@@ -53,6 +56,15 @@ function dateGroup(ts: number): string {
|
||||
return 'Earlier'
|
||||
}
|
||||
|
||||
function groupLabel(group: 'Today' | 'Yesterday' | 'Earlier', hasRuntimeSessions: boolean, t: TFunction): string {
|
||||
const label = group === 'Today'
|
||||
? t('session.today')
|
||||
: group === 'Yesterday'
|
||||
? t('session.yesterday')
|
||||
: t('session.earlier')
|
||||
return hasRuntimeSessions ? `${label} ${t('session.runtimeSuffix')}` : label
|
||||
}
|
||||
|
||||
interface SessionTree {
|
||||
session: Session
|
||||
children: Session[]
|
||||
@@ -104,6 +116,7 @@ function SessionItem({
|
||||
onSelect: () => void
|
||||
onDelete: () => void
|
||||
}) {
|
||||
const { t, translateMaybe } = useI18n()
|
||||
const [showDelete, setShowDelete] = useState(false)
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
const agentLabel = session.assigneeIds.length > 0
|
||||
@@ -134,12 +147,12 @@ function SessionItem({
|
||||
<span className="session-agent-tag">{agentLabel}</span>
|
||||
)}
|
||||
{!isChild && session.isCompanyRuntime && (
|
||||
<span className="session-runtime-badge" title="Company runtime"><IconWorkItem /></span>
|
||||
<span className="session-runtime-badge" title={t('session.companyRuntime')}><IconWorkItem /></span>
|
||||
)}
|
||||
{session.title}
|
||||
</span>
|
||||
<span className="session-item-meta">
|
||||
{displayStatus} · {relativeTime(session.updatedAt)}
|
||||
{translateMaybe('kanban.status', displayStatus) || displayStatus} · {relativeTime(session.updatedAt, t)}
|
||||
</span>
|
||||
</div>
|
||||
{!!unreadCount && unreadCount > 0 && (
|
||||
@@ -149,15 +162,15 @@ function SessionItem({
|
||||
<button
|
||||
className="session-delete-btn"
|
||||
onClick={e => { e.stopPropagation(); setConfirming(true) }}
|
||||
title="Delete"
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<IconTrash />
|
||||
</button>
|
||||
)}
|
||||
{confirming && (
|
||||
<span className="session-confirm-delete" onClick={e => e.stopPropagation()}>
|
||||
<button className="session-confirm-yes" onClick={() => { setConfirming(false); onDelete() }}>Delete</button>
|
||||
<button className="session-confirm-no" onClick={() => setConfirming(false)}>Cancel</button>
|
||||
<button className="session-confirm-yes" onClick={() => { setConfirming(false); onDelete() }}>{t('common.delete')}</button>
|
||||
<button className="session-confirm-no" onClick={() => setConfirming(false)}>{t('common.cancel')}</button>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
@@ -165,6 +178,7 @@ function SessionItem({
|
||||
}
|
||||
|
||||
export function SessionSidebar({ sessions, activeSessionId, activeChannel, secretaryChannelId, unreadCounts, onSelect, onCreateSession, onDeleteSession, onSelectSecretary }: SessionSidebarProps) {
|
||||
const { t } = useI18n()
|
||||
const [search, setSearch] = useState('')
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(new Set())
|
||||
const hasRuntimeSessions = sessions.some(session =>
|
||||
@@ -256,7 +270,7 @@ export function SessionSidebar({ sessions, activeSessionId, activeChannel, secre
|
||||
e.stopPropagation()
|
||||
toggleCollapse(node.session.taskId)
|
||||
}}
|
||||
aria-label={isCollapsed ? 'Expand' : 'Collapse'}
|
||||
aria-label={isCollapsed ? t('session.expand') : t('session.collapse')}
|
||||
/>
|
||||
)}
|
||||
<SessionItem
|
||||
@@ -269,13 +283,13 @@ export function SessionSidebar({ sessions, activeSessionId, activeChannel, secre
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}, [activeSessionId, collapsed, onDeleteSession, onSelect, toggleCollapse, unreadCounts])
|
||||
}, [activeSessionId, collapsed, onDeleteSession, onSelect, toggleCollapse, unreadCounts, t])
|
||||
|
||||
const renderVirtualRow = useCallback((row: SidebarRow) => {
|
||||
if (row.kind === 'group') {
|
||||
return (
|
||||
<div className="session-group-label">
|
||||
{hasRuntimeSessions ? `${row.group} Runtime Sessions` : row.group}
|
||||
{groupLabel(row.group, hasRuntimeSessions, t)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -288,7 +302,7 @@ export function SessionSidebar({ sessions, activeSessionId, activeChannel, secre
|
||||
className="session-child-count"
|
||||
onClick={() => toggleCollapse(row.node.session.taskId)}
|
||||
>
|
||||
{row.node.children.length} sub-task{row.node.children.length > 1 ? 's' : ''}
|
||||
{t(row.node.children.length > 1 ? 'session.subTasks' : 'session.subTask', { count: row.node.children.length })}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -305,13 +319,13 @@ export function SessionSidebar({ sessions, activeSessionId, activeChannel, secre
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}, [activeSessionId, hasRuntimeSessions, onDeleteSession, onSelect, renderPrimaryRow, toggleCollapse, unreadCounts])
|
||||
}, [activeSessionId, hasRuntimeSessions, onDeleteSession, onSelect, renderPrimaryRow, toggleCollapse, unreadCounts, t])
|
||||
|
||||
return (
|
||||
<div className="session-sidebar">
|
||||
<button className="session-new-btn" onClick={onCreateSession}>
|
||||
<IconPlus />
|
||||
<span>New Chat</span>
|
||||
<span>{t('session.newChat')}</span>
|
||||
</button>
|
||||
|
||||
{onSelectSecretary && (
|
||||
@@ -320,7 +334,7 @@ export function SessionSidebar({ sessions, activeSessionId, activeChannel, secre
|
||||
onClick={onSelectSecretary}
|
||||
>
|
||||
<IconShield />
|
||||
<span>Secretary</span>
|
||||
<span>{t('session.secretary')}</span>
|
||||
{!!(secretaryChannelId && unreadCounts?.[secretaryChannelId]) && unreadCounts![secretaryChannelId!] > 0 && (
|
||||
<span className="session-unread-badge">{unreadCounts![secretaryChannelId!] > 99 ? '99+' : unreadCounts![secretaryChannelId!]}</span>
|
||||
)}
|
||||
@@ -331,7 +345,7 @@ export function SessionSidebar({ sessions, activeSessionId, activeChannel, secre
|
||||
<IconSearch />
|
||||
<input
|
||||
className="session-search"
|
||||
placeholder="Search..."
|
||||
placeholder={t('session.search')}
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
@@ -368,7 +382,7 @@ export function SessionSidebar({ sessions, activeSessionId, activeChannel, secre
|
||||
if (!items || items.length === 0) return null
|
||||
return (
|
||||
<div key={group} className="session-group">
|
||||
<div className="session-group-label">{hasRuntimeSessions ? `${group} Runtime Sessions` : group}</div>
|
||||
<div className="session-group-label">{groupLabel(group, hasRuntimeSessions, t)}</div>
|
||||
{items.map(node => {
|
||||
const hasChildren = node.children.length > 0
|
||||
const isCollapsed = collapsed.has(node.session.taskId)
|
||||
@@ -382,7 +396,7 @@ export function SessionSidebar({ sessions, activeSessionId, activeChannel, secre
|
||||
e.stopPropagation()
|
||||
toggleCollapse(node.session.taskId)
|
||||
}}
|
||||
aria-label={isCollapsed ? 'Expand' : 'Collapse'}
|
||||
aria-label={isCollapsed ? t('session.expand') : t('session.collapse')}
|
||||
/>
|
||||
)}
|
||||
<SessionItem
|
||||
@@ -416,7 +430,7 @@ export function SessionSidebar({ sessions, activeSessionId, activeChannel, secre
|
||||
className="session-child-count"
|
||||
onClick={() => toggleCollapse(node.session.taskId)}
|
||||
>
|
||||
{node.children.length} sub-task{node.children.length > 1 ? 's' : ''}
|
||||
{t(node.children.length > 1 ? 'session.subTasks' : 'session.subTask', { count: node.children.length })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -427,7 +441,7 @@ export function SessionSidebar({ sessions, activeSessionId, activeChannel, secre
|
||||
})}
|
||||
|
||||
{filtered.length === 0 && (
|
||||
<div className="session-empty">No sessions yet</div>
|
||||
<div className="session-empty">{t('session.noSessions')}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -436,7 +450,7 @@ export function SessionSidebar({ sessions, activeSessionId, activeChannel, secre
|
||||
onClick={() => onSelect(null)}
|
||||
>
|
||||
<IconActivity />
|
||||
<span>Activity</span>
|
||||
<span>{t('session.activity')}</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import type { Project } from '../types/kanban'
|
||||
import { useI18n } from '../i18n'
|
||||
|
||||
interface ProjectSelectorProps {
|
||||
projects: Project[]
|
||||
@@ -10,6 +11,7 @@ interface ProjectSelectorProps {
|
||||
}
|
||||
|
||||
export function ProjectSelector({ projects, activeId, onSelect, onCreate, onDelete }: ProjectSelectorProps) {
|
||||
const { t } = useI18n()
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [newName, setNewName] = useState('')
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null)
|
||||
@@ -34,7 +36,8 @@ export function ProjectSelector({ projects, activeId, onSelect, onCreate, onDele
|
||||
className="theme-select"
|
||||
value={activeId}
|
||||
onChange={e => onSelect(e.target.value)}
|
||||
title="Switch project"
|
||||
title={t('project.switch')}
|
||||
aria-label={t('project.switch')}
|
||||
>
|
||||
{projects.map(p => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
@@ -49,7 +52,7 @@ export function ProjectSelector({ projects, activeId, onSelect, onCreate, onDele
|
||||
autoFocus
|
||||
className="theme-select"
|
||||
value={newName}
|
||||
placeholder="project-name"
|
||||
placeholder={t('project.placeholder')}
|
||||
onChange={e => setNewName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Escape') setCreating(false) }}
|
||||
style={{ width: 120 }}
|
||||
@@ -60,7 +63,7 @@ export function ProjectSelector({ projects, activeId, onSelect, onCreate, onDele
|
||||
<button
|
||||
className="pill-btn"
|
||||
onClick={() => setCreating(true)}
|
||||
title="New project"
|
||||
title={t('project.new')}
|
||||
style={{ fontSize: 11, padding: '2px 8px' }}
|
||||
>
|
||||
+
|
||||
@@ -70,10 +73,10 @@ export function ProjectSelector({ projects, activeId, onSelect, onCreate, onDele
|
||||
<button
|
||||
className="pill-btn"
|
||||
onClick={() => setConfirmDelete(activeId)}
|
||||
title="Delete project"
|
||||
title={t('project.delete')}
|
||||
style={{ fontSize: 11, padding: '2px 8px', color: '#ef4444' }}
|
||||
>
|
||||
Del
|
||||
{t('project.deleteButton')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -88,10 +91,10 @@ export function ProjectSelector({ projects, activeId, onSelect, onCreate, onDele
|
||||
maxWidth: 400, boxShadow: '0 8px 32px rgba(0,0,0,0.4)', textAlign: 'center',
|
||||
}}>
|
||||
<p style={{ margin: '0 0 8px', fontWeight: 600, fontSize: 15 }}>
|
||||
Delete project "{confirmDelete}"?
|
||||
{t('project.confirmTitle', { name: confirmDelete })}
|
||||
</p>
|
||||
<p style={{ margin: '0 0 20px', fontSize: 13, opacity: 0.7 }}>
|
||||
All sessions, messages, tasks, and agent data in this project will be permanently deleted. This action cannot be undone.
|
||||
{t('project.confirmBody')}
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'center' }}>
|
||||
<button
|
||||
@@ -99,14 +102,14 @@ export function ProjectSelector({ projects, activeId, onSelect, onCreate, onDele
|
||||
onClick={() => setConfirmDelete(null)}
|
||||
style={{ padding: '6px 18px', fontSize: 13 }}
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
className="pill-btn"
|
||||
onClick={handleDelete}
|
||||
style={{ padding: '6px 18px', fontSize: 13, background: '#ef4444', color: '#fff' }}
|
||||
>
|
||||
Delete
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
|
||||
export type Locale = 'en' | 'zh-CN'
|
||||
|
||||
const STORAGE_KEY = 'openopc_locale'
|
||||
|
||||
const en = {
|
||||
'app.page.workspace': 'Workspace',
|
||||
'app.page.office': 'Office',
|
||||
'app.page.org': 'Org',
|
||||
'app.page.mapEditor': 'Map editor',
|
||||
'app.metric.agents': 'agents',
|
||||
'app.metric.skills': 'skills',
|
||||
'app.metric.tasks': 'tasks',
|
||||
'language.label': 'Language',
|
||||
'language.english': 'EN',
|
||||
'language.chinese': '简',
|
||||
'outdoor.title': 'Outdoor lighting override',
|
||||
'outdoor.auto': 'Outdoor auto',
|
||||
'outdoor.day': 'Outdoor day',
|
||||
'outdoor.night': 'Outdoor night',
|
||||
'theme.midnight': 'Midnight',
|
||||
'theme.neon': 'Neon',
|
||||
'theme.paper': 'Paper',
|
||||
'theme.retro': 'Retro',
|
||||
'theme.terminal': 'Terminal',
|
||||
'theme.cozy': 'Cozy',
|
||||
'theme.openopc': 'OpenOPC',
|
||||
'dev.tools': 'Developer Tools',
|
||||
'dev.connection': 'Connection',
|
||||
'dev.evolution': 'Evolution Pipeline',
|
||||
'dev.events': 'Events',
|
||||
'dev.channels': 'Channels',
|
||||
'dev.phase.trace': 'Trace',
|
||||
'dev.phase.reflect': 'Reflect',
|
||||
'dev.phase.synthesize': 'Synthesize',
|
||||
'dev.phase.practice': 'Practice',
|
||||
'dev.phase.lifecycle': 'Lifecycle',
|
||||
'project.switch': 'Switch project',
|
||||
'project.new': 'New project',
|
||||
'project.delete': 'Delete project',
|
||||
'project.deleteButton': 'Del',
|
||||
'project.placeholder': 'project-name',
|
||||
'project.confirmTitle': 'Delete project "{name}"?',
|
||||
'project.confirmBody': 'All sessions, messages, tasks, and agent data in this project will be permanently deleted. This action cannot be undone.',
|
||||
'common.cancel': 'Cancel',
|
||||
'common.delete': 'Delete',
|
||||
'common.yes': 'Yes',
|
||||
'common.no': 'No',
|
||||
'common.loading': 'Loading...',
|
||||
'common.ready': 'ready',
|
||||
'common.autoSaved': 'Auto-saved',
|
||||
'common.active': 'active',
|
||||
'common.idle': 'idle',
|
||||
'common.general': 'general',
|
||||
'common.state': 'State',
|
||||
'common.tool': 'Tool',
|
||||
'common.task': 'Task',
|
||||
'common.seat': 'Seat',
|
||||
'common.role': 'Role',
|
||||
'common.employee': 'Employee',
|
||||
'common.manager': 'Manager',
|
||||
'common.origin': 'Origin',
|
||||
'common.projection': 'Projection',
|
||||
'common.gate': 'Gate',
|
||||
'common.runtime': 'Runtime',
|
||||
'office.modeHint.org': 'Manage your team in the Org tab',
|
||||
'office.modeHint.switch': 'Switch to Org mode to create or manage agents',
|
||||
'office.offices': 'Offices',
|
||||
'office.activeAgents': 'Active Agents',
|
||||
'office.rename': 'Rename',
|
||||
'office.noSeat': 'no seat',
|
||||
'office.moveHere': '+ Move here',
|
||||
'office.characters': 'Characters',
|
||||
'office.hideSub': 'hide sub',
|
||||
'office.showSub': 'show sub',
|
||||
'office.hideSubagents': 'Hide sub-agents',
|
||||
'office.showSubagents': 'Show sub-agents',
|
||||
'office.showSidePanel': 'Show side panel',
|
||||
'office.hideSidePanel': 'Hide side panel',
|
||||
'office.removeAgent': 'Remove {name}',
|
||||
'office.deleteQuestion': 'Delete?',
|
||||
'office.emptyAgents': 'No agents yet — click a template above to spawn one.',
|
||||
'session.newChat': 'New Chat',
|
||||
'session.secretary': 'Secretary',
|
||||
'session.search': 'Search...',
|
||||
'session.activity': 'Activity',
|
||||
'session.noSessions': 'No sessions yet',
|
||||
'session.today': 'Today',
|
||||
'session.yesterday': 'Yesterday',
|
||||
'session.earlier': 'Earlier',
|
||||
'session.runtimeSuffix': 'Runtime Sessions',
|
||||
'session.justNow': 'just now',
|
||||
'session.minutes': '{count}m',
|
||||
'session.hours': '{count}h',
|
||||
'session.days': '{count}d',
|
||||
'session.companyRuntime': 'Company runtime',
|
||||
'session.expand': 'Expand',
|
||||
'session.collapse': 'Collapse',
|
||||
'session.subTask': '{count} sub-task',
|
||||
'session.subTasks': '{count} sub-tasks',
|
||||
'workspace.selectRuntimeSession': 'Select a Runtime Session on the left to view its Work Item board.',
|
||||
'workspace.noWorkItems': 'No work items yet — start delegation to populate this board.',
|
||||
'workspace.clickEditTitle': 'Click to edit session title',
|
||||
'workspace.channel.secretary': 'Secretary',
|
||||
'workspace.channel.activity': 'Activity',
|
||||
'workspace.confirmNoRoles': 'Your org has no roles defined.\n\nSet up at least one role before running a task.\n\nGo to Org tab now?',
|
||||
'workspace.confirmNoDecider': 'Your organization has multiple top-level roles but no final decider selected.\n\nChoose one final decider in the Org tab before running a task.\n\nGo to Org tab now?',
|
||||
'workspace.warningNoTeams': 'No runtime teams defined — the system will auto-generate from your roles',
|
||||
'workspace.warningVacantRoles': '{count} role(s) have no employees: {names}',
|
||||
'workspace.confirmWarnings': 'Before running this task:\n\n{warnings}\n\nRun anyway?',
|
||||
'kanban.addTask': 'Add task',
|
||||
'kanban.taskTitle': 'Task title...',
|
||||
'kanban.startTask': 'Start task',
|
||||
'kanban.startWorkItem': 'Start Work Item',
|
||||
'kanban.upstreamDeps': '{count} upstream dep(s)',
|
||||
'kanban.dep': '{count} dep',
|
||||
'kanban.crossOffice': 'Cross-office',
|
||||
'kanban.executionTurn': 'Execution Turn: {id}',
|
||||
'kanban.column.Todo': 'Todo',
|
||||
'kanban.column.In Progress': 'In Progress',
|
||||
'kanban.column.Done': 'Done',
|
||||
'kanban.column.Ready': 'Ready',
|
||||
'kanban.column.Running': 'Running',
|
||||
'kanban.column.Review': 'Review',
|
||||
'kanban.column.Approved': 'Approved',
|
||||
'kanban.status.todo': 'To do',
|
||||
'kanban.status.in_progress': 'In progress',
|
||||
'kanban.status.in_review': 'In review',
|
||||
'kanban.status.done': 'Done',
|
||||
'kanban.status.running': 'Running',
|
||||
'kanban.status.idle': 'Idle',
|
||||
'kanban.status.blocked': 'Blocked',
|
||||
'kanban.status.awaiting_peer': 'Awaiting',
|
||||
'kanban.status.awaiting_manager_review': 'Mgr Review',
|
||||
'kanban.status.awaiting_human': 'Human Review',
|
||||
'kanban.status.awaiting_review': 'In Review',
|
||||
'kanban.status.failed': 'Failed',
|
||||
'kanban.status.cancelled': 'Cancelled',
|
||||
'agent.status.idle': 'Idle',
|
||||
'agent.status.reflecting': 'Thinking...',
|
||||
'agent.status.tool_active': 'Running tool',
|
||||
'agent.summary.active': '{active}/{total} active',
|
||||
'agent.summary.count': '{count} agent',
|
||||
'agent.summary.countPlural': '{count} agents',
|
||||
'org.loading': 'Loading organization data...',
|
||||
'org.company': 'Company',
|
||||
'org.customOrg': 'Custom org',
|
||||
'org.corporateCompany': 'Corporate company',
|
||||
'org.kind.saved': 'Saved org',
|
||||
'org.kind.corporate': 'Corporate',
|
||||
'org.state.editableSaved': 'Editable saved architecture',
|
||||
'org.state.editableDraft': 'Editable draft architecture',
|
||||
'org.state.readOnly': 'Built-in read-only architecture',
|
||||
'org.organization': 'Organization',
|
||||
'org.corporate': 'Corporate',
|
||||
'org.newOrganization': 'New organization',
|
||||
'org.roles': 'roles',
|
||||
'org.employees': 'employees',
|
||||
'org.runtimeTeams': 'runtime teams',
|
||||
'org.tab.team': 'Team',
|
||||
'org.tab.runtime': 'Runtime',
|
||||
'org.tab.architecture': 'Architecture',
|
||||
'org.tab.employees': 'Employees',
|
||||
'org.createdToast': 'Created {name} and saved automatically',
|
||||
'org.architectureApplied': 'Architecture applied successfully',
|
||||
}
|
||||
|
||||
type MessageKey = keyof typeof en
|
||||
|
||||
const zh: Record<MessageKey, string> = {
|
||||
'app.page.workspace': '工作台',
|
||||
'app.page.office': '办公室',
|
||||
'app.page.org': '组织',
|
||||
'app.page.mapEditor': '地图编辑',
|
||||
'app.metric.agents': 'Agent',
|
||||
'app.metric.skills': '技能',
|
||||
'app.metric.tasks': '任务',
|
||||
'language.label': '语言',
|
||||
'language.english': 'EN',
|
||||
'language.chinese': '简',
|
||||
'outdoor.title': '户外光照',
|
||||
'outdoor.auto': '户外自动',
|
||||
'outdoor.day': '户外白天',
|
||||
'outdoor.night': '户外夜晚',
|
||||
'theme.midnight': '午夜',
|
||||
'theme.neon': '霓虹',
|
||||
'theme.paper': '纸张',
|
||||
'theme.retro': '复古',
|
||||
'theme.terminal': '终端',
|
||||
'theme.cozy': '舒适',
|
||||
'theme.openopc': 'OpenOPC',
|
||||
'dev.tools': '开发工具',
|
||||
'dev.connection': '连接',
|
||||
'dev.evolution': '进化流水线',
|
||||
'dev.events': '事件',
|
||||
'dev.channels': '频道',
|
||||
'dev.phase.trace': '追踪',
|
||||
'dev.phase.reflect': '反思',
|
||||
'dev.phase.synthesize': '合成',
|
||||
'dev.phase.practice': '练习',
|
||||
'dev.phase.lifecycle': '生命周期',
|
||||
'project.switch': '切换项目',
|
||||
'project.new': '新建项目',
|
||||
'project.delete': '删除项目',
|
||||
'project.deleteButton': '删',
|
||||
'project.placeholder': '项目名',
|
||||
'project.confirmTitle': '删除项目“{name}”?',
|
||||
'project.confirmBody': '此项目中的所有会话、消息、任务和 Agent 数据都会被永久删除。此操作无法撤销。',
|
||||
'common.cancel': '取消',
|
||||
'common.delete': '删除',
|
||||
'common.yes': '是',
|
||||
'common.no': '否',
|
||||
'common.loading': '加载中...',
|
||||
'common.ready': '就绪',
|
||||
'common.autoSaved': '已自动保存',
|
||||
'common.active': '活跃',
|
||||
'common.idle': '空闲',
|
||||
'common.general': '通用',
|
||||
'common.state': '状态',
|
||||
'common.tool': '工具',
|
||||
'common.task': '任务',
|
||||
'common.seat': '座位',
|
||||
'common.role': '角色',
|
||||
'common.employee': '员工',
|
||||
'common.manager': '经理',
|
||||
'common.origin': '来源',
|
||||
'common.projection': '投影',
|
||||
'common.gate': '关卡',
|
||||
'common.runtime': '运行时',
|
||||
'office.modeHint.org': '在“组织”页管理你的团队',
|
||||
'office.modeHint.switch': '切换到“组织”模式以创建或管理 Agent',
|
||||
'office.offices': '办公室',
|
||||
'office.activeAgents': '活跃 Agent',
|
||||
'office.rename': '重命名',
|
||||
'office.noSeat': '未分配座位',
|
||||
'office.moveHere': '+ 移到这里',
|
||||
'office.characters': '角色',
|
||||
'office.hideSub': '隐藏子项',
|
||||
'office.showSub': '显示子项',
|
||||
'office.hideSubagents': '隐藏子 Agent',
|
||||
'office.showSubagents': '显示子 Agent',
|
||||
'office.showSidePanel': '显示侧栏',
|
||||
'office.hideSidePanel': '隐藏侧栏',
|
||||
'office.removeAgent': '移除 {name}',
|
||||
'office.deleteQuestion': '删除?',
|
||||
'office.emptyAgents': '还没有 Agent,点击上方模板生成一个。',
|
||||
'session.newChat': '新聊天',
|
||||
'session.secretary': '秘书',
|
||||
'session.search': '搜索...',
|
||||
'session.activity': '动态',
|
||||
'session.noSessions': '还没有会话',
|
||||
'session.today': '今天',
|
||||
'session.yesterday': '昨天',
|
||||
'session.earlier': '更早',
|
||||
'session.runtimeSuffix': '运行时会话',
|
||||
'session.justNow': '刚刚',
|
||||
'session.minutes': '{count} 分钟',
|
||||
'session.hours': '{count} 小时',
|
||||
'session.days': '{count} 天',
|
||||
'session.companyRuntime': '公司运行时',
|
||||
'session.expand': '展开',
|
||||
'session.collapse': '折叠',
|
||||
'session.subTask': '{count} 个子任务',
|
||||
'session.subTasks': '{count} 个子任务',
|
||||
'workspace.selectRuntimeSession': '在左侧选择一个运行时会话来查看它的工作项看板。',
|
||||
'workspace.noWorkItems': '还没有工作项,开始委派后会填充此看板。',
|
||||
'workspace.clickEditTitle': '点击编辑会话标题',
|
||||
'workspace.channel.secretary': '秘书',
|
||||
'workspace.channel.activity': '动态',
|
||||
'workspace.confirmNoRoles': '你的组织还没有定义角色。\n\n运行任务前请至少设置一个角色。\n\n现在前往“组织”页吗?',
|
||||
'workspace.confirmNoDecider': '你的组织有多个顶层角色,但尚未选择最终决策者。\n\n运行任务前请在“组织”页选择一个最终决策者。\n\n现在前往“组织”页吗?',
|
||||
'workspace.warningNoTeams': '尚未定义运行时团队,系统会根据角色自动生成',
|
||||
'workspace.warningVacantRoles': '{count} 个角色没有员工:{names}',
|
||||
'workspace.confirmWarnings': '运行此任务前:\n\n{warnings}\n\n仍然运行吗?',
|
||||
'kanban.addTask': '添加任务',
|
||||
'kanban.taskTitle': '任务标题...',
|
||||
'kanban.startTask': '启动任务',
|
||||
'kanban.startWorkItem': '启动工作项',
|
||||
'kanban.upstreamDeps': '{count} 个上游依赖',
|
||||
'kanban.dep': '{count} 依赖',
|
||||
'kanban.crossOffice': '跨办公室',
|
||||
'kanban.executionTurn': '执行轮次:{id}',
|
||||
'kanban.column.Todo': '待办',
|
||||
'kanban.column.In Progress': '进行中',
|
||||
'kanban.column.Done': '完成',
|
||||
'kanban.column.Ready': '就绪',
|
||||
'kanban.column.Running': '运行中',
|
||||
'kanban.column.Review': '评审',
|
||||
'kanban.column.Approved': '已批准',
|
||||
'kanban.status.todo': '待办',
|
||||
'kanban.status.in_progress': '进行中',
|
||||
'kanban.status.in_review': '评审中',
|
||||
'kanban.status.done': '完成',
|
||||
'kanban.status.running': '运行中',
|
||||
'kanban.status.idle': '空闲',
|
||||
'kanban.status.blocked': '阻塞',
|
||||
'kanban.status.awaiting_peer': '等待同伴',
|
||||
'kanban.status.awaiting_manager_review': '经理评审',
|
||||
'kanban.status.awaiting_human': '人工评审',
|
||||
'kanban.status.awaiting_review': '评审中',
|
||||
'kanban.status.failed': '失败',
|
||||
'kanban.status.cancelled': '已取消',
|
||||
'agent.status.idle': '空闲',
|
||||
'agent.status.reflecting': '思考中...',
|
||||
'agent.status.tool_active': '运行工具',
|
||||
'agent.summary.active': '{active}/{total} 活跃',
|
||||
'agent.summary.count': '{count} 个 Agent',
|
||||
'agent.summary.countPlural': '{count} 个 Agent',
|
||||
'org.loading': '正在加载组织数据...',
|
||||
'org.company': '公司',
|
||||
'org.customOrg': '自定义组织',
|
||||
'org.corporateCompany': '企业公司',
|
||||
'org.kind.saved': '已保存组织',
|
||||
'org.kind.corporate': '企业',
|
||||
'org.state.editableSaved': '可编辑的已保存架构',
|
||||
'org.state.editableDraft': '可编辑草稿架构',
|
||||
'org.state.readOnly': '内置只读架构',
|
||||
'org.organization': '组织',
|
||||
'org.corporate': '企业',
|
||||
'org.newOrganization': '新建组织',
|
||||
'org.roles': '角色',
|
||||
'org.employees': '员工',
|
||||
'org.runtimeTeams': '运行时团队',
|
||||
'org.tab.team': '团队',
|
||||
'org.tab.runtime': '运行时',
|
||||
'org.tab.architecture': '架构',
|
||||
'org.tab.employees': '员工',
|
||||
'org.createdToast': '已创建 {name} 并自动保存',
|
||||
'org.architectureApplied': '架构已成功应用',
|
||||
}
|
||||
|
||||
const dictionaries = { en, 'zh-CN': zh } satisfies Record<Locale, Record<MessageKey, string>>
|
||||
|
||||
interface I18nContextValue {
|
||||
locale: Locale
|
||||
setLocale: (locale: Locale) => void
|
||||
t: (key: MessageKey, params?: Record<string, string | number>) => string
|
||||
translateMaybe: (namespace: string, value: string | undefined | null) => string
|
||||
}
|
||||
|
||||
const I18nContext = createContext<I18nContextValue | null>(null)
|
||||
|
||||
function readInitialLocale(): Locale {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored === 'en' || stored === 'zh-CN') return stored
|
||||
} catch {
|
||||
// private mode
|
||||
}
|
||||
try {
|
||||
return navigator.language.toLowerCase().startsWith('zh') ? 'zh-CN' : 'en'
|
||||
} catch {
|
||||
return 'en'
|
||||
}
|
||||
}
|
||||
|
||||
function interpolate(template: string, params?: Record<string, string | number>): string {
|
||||
if (!params) return template
|
||||
return template.replace(/\{(\w+)\}/g, (_, key: string) => (
|
||||
Object.prototype.hasOwnProperty.call(params, key) ? String(params[key]) : `{${key}}`
|
||||
))
|
||||
}
|
||||
|
||||
export function I18nProvider({ children }: { children: ReactNode }) {
|
||||
const [locale, setLocaleState] = useState<Locale>(() => readInitialLocale())
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = locale === 'zh-CN' ? 'zh-CN' : 'en'
|
||||
}, [locale])
|
||||
|
||||
const setLocale = useCallback((next: Locale) => {
|
||||
setLocaleState(next)
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, next)
|
||||
document.documentElement.lang = next === 'zh-CN' ? 'zh-CN' : 'en'
|
||||
} catch {
|
||||
// private mode
|
||||
}
|
||||
}, [])
|
||||
|
||||
const t = useCallback<I18nContextValue['t']>((key, params) => {
|
||||
const template = dictionaries[locale][key] ?? dictionaries.en[key] ?? key
|
||||
return interpolate(template, params)
|
||||
}, [locale])
|
||||
|
||||
const translateMaybe = useCallback<I18nContextValue['translateMaybe']>((namespace, value) => {
|
||||
const normalized = String(value ?? '').trim()
|
||||
if (!normalized) return ''
|
||||
const key = `${namespace}.${normalized}` as MessageKey
|
||||
return dictionaries[locale][key] ?? normalized
|
||||
}, [locale])
|
||||
|
||||
const value = useMemo<I18nContextValue>(() => ({ locale, setLocale, t, translateMaybe }), [locale, setLocale, t, translateMaybe])
|
||||
|
||||
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>
|
||||
}
|
||||
|
||||
export function useI18n(): I18nContextValue {
|
||||
const ctx = useContext(I18nContext)
|
||||
if (!ctx) throw new Error('useI18n must be used within I18nProvider')
|
||||
return ctx
|
||||
}
|
||||
@@ -426,6 +426,41 @@ html, body, #root {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.language-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
height: 30px;
|
||||
padding: 2px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.language-toggle-btn {
|
||||
flex: 0 0 auto;
|
||||
min-width: 34px;
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
border: 0;
|
||||
border-radius: calc(var(--radius-sm) - 2px);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.language-toggle-btn:hover {
|
||||
color: var(--text);
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
.language-toggle-btn.active {
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.theme-select {
|
||||
font-size: 11px;
|
||||
padding: 4px 8px;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { AgentInfo } from '../types/visual'
|
||||
import type { AgentAnimStatus, KanbanTask } from '../types/kanban'
|
||||
import { AGENT_STATUS_LABEL } from '../types/kanban'
|
||||
import type { AgentInfo } from '../types/visual'
|
||||
import type { AgentAnimStatus, KanbanTask } from '../types/kanban'
|
||||
import { AGENT_STATUS_LABEL } from '../types/kanban'
|
||||
import { useI18n } from '../i18n'
|
||||
|
||||
interface AgentStatusBarProps {
|
||||
agents: AgentInfo[]
|
||||
@@ -15,8 +16,9 @@ interface AgentState {
|
||||
taskDisplayId?: string
|
||||
}
|
||||
|
||||
export function AgentStatusBar({ agents, tasks }: AgentStatusBarProps) {
|
||||
const agentStates = useMemo<AgentState[]>(() => {
|
||||
export function AgentStatusBar({ agents, tasks }: AgentStatusBarProps) {
|
||||
const { t, translateMaybe } = useI18n()
|
||||
const agentStates = useMemo<AgentState[]>(() => {
|
||||
const tasksById = new Map(tasks.map((task) => [task.id, task]))
|
||||
return agents.map(agent => {
|
||||
// Find the first active (non-idle) task for this agent
|
||||
@@ -38,36 +40,41 @@ export function AgentStatusBar({ agents, tasks }: AgentStatusBarProps) {
|
||||
|
||||
const activeCount = agentStates.filter(s => s.status !== 'idle').length
|
||||
|
||||
return (
|
||||
<div className="agent-status-bar">
|
||||
<span className="agent-status-summary">
|
||||
{activeCount > 0
|
||||
? `${activeCount}/${agents.length} active`
|
||||
: `${agents.length} agent${agents.length !== 1 ? 's' : ''}`}
|
||||
</span>
|
||||
<div className="agent-status-chips">
|
||||
{agentStates.map(({ agent, status, currentTool, taskDisplayId }) => (
|
||||
<div
|
||||
key={agent.agent_id}
|
||||
className={`agent-status-chip status-${status}`}
|
||||
title={`${agent.name}: ${status === 'tool_active' && currentTool ? currentTool : AGENT_STATUS_LABEL[status]}${taskDisplayId ? ` (${taskDisplayId})` : ''}`}
|
||||
>
|
||||
<span className="agent-status-avatar">{agent.name.charAt(0).toUpperCase()}</span>
|
||||
<span className="agent-status-name">{agent.name}</span>
|
||||
{status !== 'idle' && (
|
||||
<>
|
||||
<span className="kanban-runtime-dot" />
|
||||
<span className="agent-status-detail">
|
||||
{status === 'tool_active' && currentTool ? currentTool : AGENT_STATUS_LABEL[status]}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{taskDisplayId && (
|
||||
<span className="agent-status-task">{taskDisplayId}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="agent-status-bar">
|
||||
<span className="agent-status-summary">
|
||||
{activeCount > 0
|
||||
? t('agent.summary.active', { active: activeCount, total: agents.length })
|
||||
: t(agents.length !== 1 ? 'agent.summary.countPlural' : 'agent.summary.count', { count: agents.length })}
|
||||
</span>
|
||||
<div className="agent-status-chips">
|
||||
{agentStates.map(({ agent, status, currentTool, taskDisplayId }) => {
|
||||
const statusLabel = status === 'tool_active' && currentTool
|
||||
? currentTool
|
||||
: translateMaybe('agent.status', status) || AGENT_STATUS_LABEL[status]
|
||||
return (
|
||||
<div
|
||||
key={agent.agent_id}
|
||||
className={`agent-status-chip status-${status}`}
|
||||
title={`${agent.name}: ${statusLabel}${taskDisplayId ? ` (${taskDisplayId})` : ''}`}
|
||||
>
|
||||
<span className="agent-status-avatar">{agent.name.charAt(0).toUpperCase()}</span>
|
||||
<span className="agent-status-name">{agent.name}</span>
|
||||
{status !== 'idle' && (
|
||||
<>
|
||||
<span className="kanban-runtime-dot" />
|
||||
<span className="agent-status-detail">
|
||||
{statusLabel}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{taskDisplayId && (
|
||||
<span className="agent-status-task">{taskDisplayId}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { KanbanBoard } from '../types/kanban'
|
||||
import { useI18n } from '../i18n'
|
||||
|
||||
interface BoardSelectorProps {
|
||||
boards: KanbanBoard[]
|
||||
@@ -7,6 +8,7 @@ interface BoardSelectorProps {
|
||||
}
|
||||
|
||||
export function BoardSelector({ boards, activeBoardId, onSelect }: BoardSelectorProps) {
|
||||
const { translateMaybe } = useI18n()
|
||||
return (
|
||||
<div className="board-selector">
|
||||
<div className="board-tabs">
|
||||
@@ -18,7 +20,7 @@ export function BoardSelector({ boards, activeBoardId, onSelect }: BoardSelector
|
||||
onClick={() => onSelect(b.id)}
|
||||
>
|
||||
<span className="board-tab-dot" style={{ background: b.color }} />
|
||||
{b.name}
|
||||
{translateMaybe('kanban.column', b.name) || b.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { AgentInfo } from '../types/visual'
|
||||
import { PRIORITY_META, AGENT_STATUS_LABEL, type KanbanTask } from '../types/kanban'
|
||||
import { getWorkItemRoleLabel, humanizeWorkItemRoleId } from '../lib/workItemIdentity'
|
||||
import { getLinkedRuntimeTaskId } from '../lib/workItemRuntimeIds'
|
||||
import { useI18n } from '../i18n'
|
||||
|
||||
const STATUS_BADGE: Record<string, { label: string; color: string }> = {
|
||||
todo: { label: 'To do', color: '#9ca3af' },
|
||||
@@ -32,7 +33,8 @@ interface KanbanCardProps {
|
||||
}
|
||||
|
||||
export function KanbanCard({ task, index, agents, officeMap, companyMode, isSelected, onClick, onStart }: KanbanCardProps) {
|
||||
const assignees = task.assigneeIds
|
||||
const { t, translateMaybe } = useI18n()
|
||||
const assignees = task.assigneeIds
|
||||
.map(id => agents.find(a => a.agent_id === id))
|
||||
.filter(Boolean) as AgentInfo[]
|
||||
const priority = task.priority ? PRIORITY_META[task.priority] : null
|
||||
@@ -54,6 +56,9 @@ export function KanbanCard({ task, index, agents, officeMap, companyMode, isSele
|
||||
const blockerLabel = (task.blockedReason ?? '').trim()
|
||||
const reworkLabel = (task.reworkFeedback ?? '').trim()
|
||||
const linkedRuntimeTaskId = getLinkedRuntimeTaskId(task)
|
||||
const runtimeStatusLabel = task.agentStatus
|
||||
? (translateMaybe('agent.status', task.agentStatus) || AGENT_STATUS_LABEL[task.agentStatus] || task.agentStatus)
|
||||
: ''
|
||||
|
||||
return (
|
||||
<Draggable draggableId={task.id} index={index} isDragDisabled={!!companyMode}>
|
||||
@@ -66,22 +71,22 @@ export function KanbanCard({ task, index, agents, officeMap, companyMode, isSele
|
||||
data-task-id={task.id}
|
||||
onMouseUp={e => { if (e.button === 0 && !snapshot.isDragging) onClick(task) }}
|
||||
>
|
||||
<div className="kanban-card-top">
|
||||
<span className="kanban-card-id">{task.displayId}</span>
|
||||
{statusBadge && (
|
||||
<span className="kanban-status-badge" style={{ color: statusBadge.color }}>
|
||||
<span style={{ display: 'inline-block', width: 6, height: 6, borderRadius: '50%', background: statusBadge.color, marginRight: 3 }} />
|
||||
{statusBadge.label}
|
||||
</span>
|
||||
)}
|
||||
{depCount > 0 && (
|
||||
<span className="kanban-dep-badge" title={`${depCount} upstream dep(s)`}>{depCount} dep</span>
|
||||
)}
|
||||
{crossOffice && <span className="kanban-cross-badge" title="Cross-office">⇄</span>}
|
||||
<div className="kanban-card-top">
|
||||
<span className="kanban-card-id">{task.displayId}</span>
|
||||
{statusBadge && (
|
||||
<span className="kanban-status-badge" style={{ color: statusBadge.color }}>
|
||||
<span style={{ display: 'inline-block', width: 6, height: 6, borderRadius: '50%', background: statusBadge.color, marginRight: 3 }} />
|
||||
{translateMaybe('kanban.status', phaseBadge) || statusBadge.label}
|
||||
</span>
|
||||
)}
|
||||
{depCount > 0 && (
|
||||
<span className="kanban-dep-badge" title={t('kanban.upstreamDeps', { count: depCount })}>{t('kanban.dep', { count: depCount })}</span>
|
||||
)}
|
||||
{crossOffice && <span className="kanban-cross-badge" title={t('kanban.crossOffice')}>⇄</span>}
|
||||
{onStart && (
|
||||
<button
|
||||
className="kanban-start-btn"
|
||||
title={companyMode ? 'Start Work Item' : 'Start task'}
|
||||
title={companyMode ? t('kanban.startWorkItem') : t('kanban.startTask')}
|
||||
onClick={e => { e.stopPropagation(); onStart(task.id) }}
|
||||
>
|
||||
▶
|
||||
@@ -94,34 +99,34 @@ export function KanbanCard({ task, index, agents, officeMap, companyMode, isSele
|
||||
{(roleLabel || employee?.name || gate?.type || task.originChannel || task.workItemProjectionId) && (
|
||||
<div className="kanban-card-meta-row">
|
||||
{roleLabel && (
|
||||
<span className="kanban-meta-badge kanban-role-badge" title={`Role: ${roleLabel}`}>
|
||||
<span className="kanban-meta-badge kanban-role-badge" title={`${t('common.role')}: ${roleLabel}`}>
|
||||
{roleLabel}
|
||||
</span>
|
||||
)}
|
||||
{employee?.name && (
|
||||
<span className="kanban-meta-badge kanban-employee-badge" title={`Employee: ${employee.name}${employee.category ? ` (${employee.category})` : ''}`}>
|
||||
<span className="kanban-meta-badge kanban-employee-badge" title={`${t('common.employee')}: ${employee.name}${employee.category ? ` (${employee.category})` : ''}`}>
|
||||
<span className="kanban-meta-icon">👤</span>
|
||||
{employee.name}
|
||||
</span>
|
||||
)}
|
||||
{employee.name}
|
||||
</span>
|
||||
)}
|
||||
{task.workItemProjectionId && (
|
||||
<span className="kanban-meta-badge kanban-projection-badge" title={`Projection: ${task.workItemProjectionId}`}>
|
||||
<span className="kanban-meta-badge kanban-projection-badge" title={`${t('common.projection')}: ${task.workItemProjectionId}`}>
|
||||
{task.workItemProjectionId}
|
||||
</span>
|
||||
)}
|
||||
{gate?.type && (
|
||||
<span className={`kanban-meta-badge kanban-gate-badge kanban-gate-${gate.type}`} title={`Gate: ${gate.type}${gate.reviewerRole ? ` by ${gate.reviewerRole}` : ''}`}>
|
||||
{gate.type === 'review' ? '\u2709' : gate.type === 'approval' ? '\u2713' : '\u270B'}
|
||||
{gate.type}
|
||||
</span>
|
||||
)}
|
||||
{gate?.type && (
|
||||
<span className={`kanban-meta-badge kanban-gate-badge kanban-gate-${gate.type}`} title={`${t('common.gate')}: ${gate.type}${gate.reviewerRole ? ` by ${gate.reviewerRole}` : ''}`}>
|
||||
{gate.type === 'review' ? '\u2709' : gate.type === 'approval' ? '\u2713' : '\u270B'}
|
||||
{gate.type}
|
||||
</span>
|
||||
)}
|
||||
{task.originChannel && (
|
||||
<span className="kanban-meta-badge kanban-origin-badge" title={`Origin: ${task.originChannel}`}>
|
||||
<span className="kanban-meta-badge kanban-origin-badge" title={`${t('common.origin')}: ${task.originChannel}`}>
|
||||
#{task.originChannel}
|
||||
</span>
|
||||
)}
|
||||
{managerLabel && (
|
||||
<span className="kanban-meta-badge" title={`Manager: ${managerLabel}`}>
|
||||
<span className="kanban-meta-badge" title={`${t('common.manager')}: ${managerLabel}`}>
|
||||
{managerLabel}
|
||||
</span>
|
||||
)}
|
||||
@@ -133,8 +138,8 @@ export function KanbanCard({ task, index, agents, officeMap, companyMode, isSele
|
||||
{blockerLabel && <span className="kanban-tag">{blockerLabel}</span>}
|
||||
{reworkLabel && <span className="kanban-tag">{reworkLabel}</span>}
|
||||
{linkedRuntimeTaskId && (
|
||||
<span className="kanban-tag" title={`Execution Turn: ${linkedRuntimeTaskId}`}>
|
||||
Runtime
|
||||
<span className="kanban-tag" title={t('kanban.executionTurn', { id: linkedRuntimeTaskId })}>
|
||||
{t('common.runtime')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -144,10 +149,10 @@ export function KanbanCard({ task, index, agents, officeMap, companyMode, isSele
|
||||
<div className={`kanban-card-runtime status-${task.agentStatus}`}>
|
||||
<span className="kanban-runtime-dot" />
|
||||
<span className="kanban-runtime-label">
|
||||
{task.agentStatus === 'tool_active' && task.currentTool
|
||||
? task.currentTool
|
||||
: AGENT_STATUS_LABEL[task.agentStatus!] ?? task.agentStatus}
|
||||
</span>
|
||||
{task.agentStatus === 'tool_active' && task.currentTool
|
||||
? task.currentTool
|
||||
: runtimeStatusLabel}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { Droppable } from '@hello-pangea/dnd'
|
||||
import type { AgentInfo } from '../types/visual'
|
||||
import type { KanbanColumn as KanbanColumnType, KanbanTask } from '../types/kanban'
|
||||
import { KanbanCard } from './KanbanCard'
|
||||
import type { AgentInfo } from '../types/visual'
|
||||
import type { KanbanColumn as KanbanColumnType, KanbanTask } from '../types/kanban'
|
||||
import { KanbanCard } from './KanbanCard'
|
||||
import { useI18n } from '../i18n'
|
||||
|
||||
interface KanbanColumnProps {
|
||||
column: KanbanColumnType
|
||||
@@ -17,7 +18,8 @@ interface KanbanColumnProps {
|
||||
}
|
||||
|
||||
export function KanbanColumn({ column, tasks, agents, officeMap, companyMode, selectedTaskId, onCardClick, onStartTask, onQuickCreate }: KanbanColumnProps) {
|
||||
const [adding, setAdding] = useState(false)
|
||||
const { t, translateMaybe } = useI18n()
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [draft, setDraft] = useState('')
|
||||
const committedRef = useRef(false)
|
||||
|
||||
@@ -35,12 +37,12 @@ export function KanbanColumn({ column, tasks, agents, officeMap, companyMode, se
|
||||
return (
|
||||
<div className="kanban-column">
|
||||
<div className="kanban-column-header">
|
||||
<span className="kanban-col-dot" style={{ background: column.color }} />
|
||||
<span className="kanban-col-label">{column.name}</span>
|
||||
<span className="kanban-col-count">{tasks.length}</span>
|
||||
{onQuickCreate && (
|
||||
<button className="kanban-col-add" title="Add task" onClick={() => { committedRef.current = false; setAdding(true) }}>+</button>
|
||||
)}
|
||||
<span className="kanban-col-dot" style={{ background: column.color }} />
|
||||
<span className="kanban-col-label">{translateMaybe('kanban.column', column.name) || column.name}</span>
|
||||
<span className="kanban-col-count">{tasks.length}</span>
|
||||
{onQuickCreate && (
|
||||
<button className="kanban-col-add" title={t('kanban.addTask')} onClick={() => { committedRef.current = false; setAdding(true) }}>+</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{adding && (
|
||||
@@ -54,7 +56,7 @@ export function KanbanColumn({ column, tasks, agents, officeMap, companyMode, se
|
||||
if (e.key === 'Escape') { committedRef.current = true; setDraft(''); setAdding(false) }
|
||||
}}
|
||||
onBlur={commitAdd}
|
||||
placeholder="Task title..."
|
||||
placeholder={t('kanban.taskTitle')}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './App'
|
||||
import { I18nProvider } from './i18n'
|
||||
import './index.css'
|
||||
|
||||
class ErrorBoundary extends React.Component<
|
||||
@@ -66,7 +67,9 @@ if (!root) {
|
||||
try {
|
||||
createRoot(root).render(
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
<I18nProvider>
|
||||
<App />
|
||||
</I18nProvider>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
} catch (err) {
|
||||
|
||||
@@ -18,6 +18,7 @@ import { EmployeesMarketplace } from './EmployeesMarketplace'
|
||||
import { ConfigImportExportPanel } from './ConfigImportExportPanel'
|
||||
import { OrgCreateModal } from './OrgCreateModal'
|
||||
import { getRuntimeOrgView } from '../lib/runtimeOrg'
|
||||
import { useI18n } from '../i18n'
|
||||
import './org.css'
|
||||
import './team.css'
|
||||
import './marketplace.css'
|
||||
@@ -122,6 +123,7 @@ export function OrgTab({
|
||||
onSavedOrgsList, onSavedOrgSaveAs, onSavedOrgCreate, onSavedOrgLoad, onSavedOrgDelete, savedOrgsList,
|
||||
activeSavedOrg, activeSavedOrgVersionAtLoad, orgCreatePending, orgCreateResult, onSelectCorporate,
|
||||
}: OrgTabProps) {
|
||||
const { t } = useI18n()
|
||||
const [activeTab, setActiveTab] = useState<SubTab>('team')
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
|
||||
@@ -166,8 +168,8 @@ export function OrgTab({
|
||||
if (!orgCreateResult || !orgCreateResult.ok) return
|
||||
setCreateOpen(false)
|
||||
setActiveTab('team')
|
||||
showToast(`Created ${orgCreateResult.organization_name || orgCreateResult.name} and saved automatically`)
|
||||
}, [orgCreateResult, showToast])
|
||||
showToast(t('org.createdToast', { name: orgCreateResult.organization_name || orgCreateResult.name }))
|
||||
}, [orgCreateResult, showToast, t])
|
||||
|
||||
// In org mode: show only user-owned roles. In company mode: show all roles read-only.
|
||||
const allRoles = data?.roles ?? []
|
||||
@@ -176,13 +178,13 @@ export function OrgTab({
|
||||
const runtimeView = useMemo(() => getRuntimeOrgView(data), [data])
|
||||
const activeAgents = useMemo(() => displayEmployees.filter(e => e.linked_agent_id), [displayEmployees])
|
||||
const configuredOrgName = data?.organization_name?.trim()
|
||||
const activeOrgLabel = configuredOrgName || humanizeOrgName(activeSavedOrg) || (isCustomMode ? 'Custom org' : 'Corporate company')
|
||||
const activeOrgLabel = configuredOrgName || humanizeOrgName(activeSavedOrg) || (isCustomMode ? t('org.customOrg') : t('org.corporateCompany'))
|
||||
const activeOrgId = (isCustomMode ? (data?.organization_id || activeSavedOrg) : 'corporate') || ''
|
||||
const architectureKindLabel = isCustomMode ? 'Saved org' : 'Corporate'
|
||||
const architectureKindLabel = isCustomMode ? t('org.kind.saved') : t('org.kind.corporate')
|
||||
const architectureStateLabel = isCustomMode
|
||||
? activeSavedOrg ? 'Editable saved architecture' : 'Editable draft architecture'
|
||||
: 'Built-in read-only architecture'
|
||||
const runtimeStateLabel = runtimeView.frontier.status || runtimeView.projectRun?.status || runtimeView.projectRun?.lifecycle_status || 'ready'
|
||||
? activeSavedOrg ? t('org.state.editableSaved') : t('org.state.editableDraft')
|
||||
: t('org.state.readOnly')
|
||||
const runtimeStateLabel = runtimeView.frontier.status || runtimeView.projectRun?.status || runtimeView.projectRun?.lifecycle_status || t('common.ready')
|
||||
|
||||
// Roles that already have at least one non-placeholder employee.
|
||||
const filledRoleIds = useMemo(
|
||||
@@ -210,9 +212,9 @@ export function OrgTab({
|
||||
setApplyingPresetId(null)
|
||||
versionAtApply.current = -1
|
||||
setActiveTab('team')
|
||||
showToast('Architecture applied successfully')
|
||||
showToast(t('org.architectureApplied'))
|
||||
}
|
||||
}, [orgVersion, applyingPresetId, showToast])
|
||||
}, [orgVersion, applyingPresetId, showToast, t])
|
||||
|
||||
const handleApplyPreset = (presetId: string, strategy: string) => {
|
||||
versionAtApply.current = orgVersion // snapshot current version
|
||||
@@ -232,7 +234,7 @@ export function OrgTab({
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <div className="org-tab"><div className="org-loading">Loading organization data...</div></div>
|
||||
return <div className="org-tab"><div className="org-loading">{t('org.loading')}</div></div>
|
||||
}
|
||||
|
||||
const installedPackages = data.installed_packages ?? []
|
||||
@@ -244,7 +246,7 @@ export function OrgTab({
|
||||
<div className={`org-header${isCustomMode ? ' org-header--custom' : ' org-header--corporate'}`}>
|
||||
<div className="org-header-main">
|
||||
<div className="org-eyebrow">
|
||||
<span>Company</span>
|
||||
<span>{t('org.company')}</span>
|
||||
<span className="org-eyebrow-separator">/</span>
|
||||
<span>{architectureKindLabel}</span>
|
||||
</div>
|
||||
@@ -259,13 +261,13 @@ export function OrgTab({
|
||||
<span className="org-meta-pill">{data.company_profile || (isCustomMode ? 'custom' : 'corporate')}</span>
|
||||
{activeOrgId && <code className="org-meta-code">{activeOrgId}</code>}
|
||||
<span className="org-meta-pill org-meta-pill--runtime">{runtimeStateLabel}</span>
|
||||
<span className="org-meta-pill org-meta-pill--saved">Auto-saved</span>
|
||||
<span className="org-meta-pill org-meta-pill--saved">{t('common.autoSaved')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="org-control-panel">
|
||||
<label className="org-switcher">
|
||||
<span className="org-switcher-label">Organization</span>
|
||||
<span className="org-switcher-label">{t('org.organization')}</span>
|
||||
<span className="org-switcher-select-wrap">
|
||||
<select
|
||||
className="org-switcher-select"
|
||||
@@ -273,9 +275,9 @@ export function OrgTab({
|
||||
onChange={e => handleOrgSelection(e.target.value)}
|
||||
onFocus={() => onSavedOrgsList?.()}
|
||||
onPointerDown={() => onSavedOrgsList?.()}
|
||||
aria-label="Organization"
|
||||
aria-label={t('org.organization')}
|
||||
>
|
||||
<option value="corporate">Corporate</option>
|
||||
<option value="corporate">{t('org.corporate')}</option>
|
||||
{savedOrgOptions.map(org => (
|
||||
<option key={org.name} value={`org:${org.name}`}>
|
||||
{(org.organization_name || org.name).trim() || org.name}
|
||||
@@ -286,36 +288,36 @@ export function OrgTab({
|
||||
</label>
|
||||
<button type="button" className="org-create-trigger" onClick={() => setCreateOpen(true)}>
|
||||
<span className="org-create-trigger-icon" aria-hidden>+</span>
|
||||
New organization
|
||||
{t('org.newOrganization')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="org-stats-strip">
|
||||
<span className="org-stat">
|
||||
<img src={STAT_ICON.agents} alt="" className="org-stat-icon" />
|
||||
<b>{displayRoles.length}</b> roles
|
||||
<b>{displayRoles.length}</b> {t('org.roles')}
|
||||
</span>
|
||||
<span className="org-stat">
|
||||
<img src={TAB_ICON.employees} alt="" className="org-stat-icon" />
|
||||
<b>{displayEmployees.length}</b> employees
|
||||
<b>{displayEmployees.length}</b> {t('org.employees')}
|
||||
</span>
|
||||
<span className="org-stat">
|
||||
<img src={TAB_ICON.runtime} alt="" className="org-stat-icon" />
|
||||
<b>{runtimeView.runtimeTeams.length}</b> runtime teams
|
||||
<b>{runtimeView.runtimeTeams.length}</b> {t('org.runtimeTeams')}
|
||||
</span>
|
||||
<span className="org-stat">
|
||||
<img src={STAT_ICON.active} alt="" className="org-stat-icon" />
|
||||
<b>{activeAgents.length}</b> active
|
||||
<b>{activeAgents.length}</b> {t('common.active')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="org-subtabs">
|
||||
{([
|
||||
{ id: 'team' as SubTab, icon: TAB_ICON.team, label: 'Team', count: displayRoles.length },
|
||||
{ id: 'runtime' as SubTab, icon: TAB_ICON.runtime, label: 'Runtime', count: runtimeView.runtimeTeams.length },
|
||||
{ id: 'architecture' as SubTab, icon: TAB_ICON.architecture, label: 'Architecture', count: marketPresets?.length ?? 0 },
|
||||
{ id: 'employees' as SubTab, icon: TAB_ICON.employees, label: 'Employees', count: talents.length },
|
||||
{ id: 'team' as SubTab, icon: TAB_ICON.team, label: t('org.tab.team'), count: displayRoles.length },
|
||||
{ id: 'runtime' as SubTab, icon: TAB_ICON.runtime, label: t('org.tab.runtime'), count: runtimeView.runtimeTeams.length },
|
||||
{ id: 'architecture' as SubTab, icon: TAB_ICON.architecture, label: t('org.tab.architecture'), count: marketPresets?.length ?? 0 },
|
||||
{ id: 'employees' as SubTab, icon: TAB_ICON.employees, label: t('org.tab.employees'), count: talents.length },
|
||||
]).map(tab => (
|
||||
<button key={tab.id}
|
||||
className={`org-subtab${activeTab === tab.id ? ' org-subtab--active' : ''}`}
|
||||
|
||||
@@ -22,6 +22,7 @@ import { getRuntimeOrgView } from '../lib/runtimeOrg'
|
||||
import { getLinkedRuntimeTaskId } from '../lib/workItemRuntimeIds'
|
||||
import { ContextPanel } from './ContextPanel'
|
||||
import { useResizePanel } from './useResizePanel'
|
||||
import { useI18n } from '../i18n'
|
||||
|
||||
type ActiveView =
|
||||
| { kind: 'session'; taskId: string }
|
||||
@@ -42,11 +43,25 @@ function makeOptimisticUserMessageId(): string {
|
||||
|
||||
/* ── Org mode pre-run readiness check ─────────────────────────────────── */
|
||||
|
||||
type TFunction = ReturnType<typeof useI18n>['t']
|
||||
|
||||
function checkOrgModeReadiness(
|
||||
mode: string,
|
||||
orgInfoData: OrgInfoPayload | null | undefined,
|
||||
onNavigateToOrg?: () => void,
|
||||
t?: TFunction,
|
||||
): boolean {
|
||||
const tr = t ?? ((key: string, params?: Record<string, string | number>) => {
|
||||
const fallbacks: Record<string, string> = {
|
||||
'workspace.confirmNoRoles': 'Your org has no roles defined.\n\nSet up at least one role before running a task.\n\nGo to Org tab now?',
|
||||
'workspace.confirmNoDecider': 'Your organization has multiple top-level roles but no final decider selected.\n\nChoose one final decider in the Org tab before running a task.\n\nGo to Org tab now?',
|
||||
'workspace.warningNoTeams': 'No runtime teams defined — the system will auto-generate from your roles',
|
||||
'workspace.warningVacantRoles': '{count} role(s) have no employees: {names}',
|
||||
'workspace.confirmWarnings': 'Before running this task:\n\n{warnings}\n\nRun anyway?',
|
||||
}
|
||||
const template = fallbacks[key] ?? key
|
||||
return template.replace(/\{(\w+)\}/g, (_, name) => String(params?.[name] ?? `{${name}}`))
|
||||
}) as TFunction
|
||||
if (mode !== 'org' && mode !== 'custom') return true
|
||||
if (!orgInfoData) return true
|
||||
|
||||
@@ -57,21 +72,13 @@ function checkOrgModeReadiness(
|
||||
|
||||
// Block: no roles defined
|
||||
if (roles.length === 0) {
|
||||
const goToOrg = confirm(
|
||||
'Your org has no roles defined.\n\n' +
|
||||
'Set up at least one role before running a task.\n\n' +
|
||||
'Go to Org tab now?'
|
||||
)
|
||||
const goToOrg = confirm(tr('workspace.confirmNoRoles'))
|
||||
if (goToOrg) onNavigateToOrg?.()
|
||||
return false
|
||||
}
|
||||
|
||||
if (topLevelRoleIds.length > 1 && !finalDeciderRoleId) {
|
||||
const goToOrg = confirm(
|
||||
'Your organization has multiple top-level roles but no final decider selected.\n\n' +
|
||||
'Choose one final decider in the Org tab before running a task.\n\n' +
|
||||
'Go to Org tab now?'
|
||||
)
|
||||
const goToOrg = confirm(tr('workspace.confirmNoDecider'))
|
||||
if (goToOrg) onNavigateToOrg?.()
|
||||
return false
|
||||
}
|
||||
@@ -84,7 +91,7 @@ function checkOrgModeReadiness(
|
||||
|| (team.manager_role_id && roleIds.has(team.manager_role_id))
|
||||
))
|
||||
if (relevantTeams.length === 0) {
|
||||
warnings.push('No runtime teams defined \u2014 the system will auto-generate from your roles')
|
||||
warnings.push(tr('workspace.warningNoTeams'))
|
||||
}
|
||||
|
||||
// Warn: roles without employees
|
||||
@@ -92,15 +99,11 @@ function checkOrgModeReadiness(
|
||||
const vacantRoles = roles.filter(r => !employeeRoleIds.has(r.role_id))
|
||||
if (vacantRoles.length > 0) {
|
||||
const names = vacantRoles.map(r => r.name).join(', ')
|
||||
warnings.push(`${vacantRoles.length} role(s) have no employees: ${names}`)
|
||||
warnings.push(tr('workspace.warningVacantRoles', { count: vacantRoles.length, names }))
|
||||
}
|
||||
|
||||
if (warnings.length > 0) {
|
||||
return confirm(
|
||||
'Before running this task:\n\n' +
|
||||
warnings.map(w => '\u2022 ' + w).join('\n') +
|
||||
'\n\nRun anyway?'
|
||||
)
|
||||
return confirm(tr('workspace.confirmWarnings', { warnings: warnings.map(w => '\u2022 ' + w).join('\n') }))
|
||||
}
|
||||
|
||||
return true
|
||||
@@ -158,6 +161,7 @@ function BoardTitleEditor({
|
||||
title: string
|
||||
onCommit: (next: string) => void
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [draft, setDraft] = useState(title)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
@@ -206,7 +210,7 @@ function BoardTitleEditor({
|
||||
<span
|
||||
className="board-tab-title"
|
||||
onClick={startEditing}
|
||||
title="Click to edit session title"
|
||||
title={t('workspace.clickEditTitle')}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
@@ -310,6 +314,7 @@ export function WorkspacePage({
|
||||
onSavedOrgsList,
|
||||
onSavedOrgLoad,
|
||||
}: WorkspacePageProps) {
|
||||
const { t } = useI18n()
|
||||
const { sessions, activeSessionId, activeSession } = sessionStore
|
||||
const { markRead } = chatStore
|
||||
|
||||
@@ -933,7 +938,7 @@ export function WorkspacePage({
|
||||
: undefined
|
||||
|
||||
// Pre-run readiness check for org mode
|
||||
if (!checkOrgModeReadiness(sessionExecMode, orgInfoData, onNavigateToOrg)) {
|
||||
if (!checkOrgModeReadiness(sessionExecMode, orgInfoData, onNavigateToOrg, t)) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -948,7 +953,7 @@ export function WorkspacePage({
|
||||
if (inProgressCol) {
|
||||
boardStore.moveTask(taskId, inProgressCol.id, 0)
|
||||
}
|
||||
}, [boardStore, onRunTask, sessions, execMode, companyProfile, orgInfoData, onNavigateToOrg])
|
||||
}, [boardStore, onRunTask, sessions, execMode, companyProfile, orgInfoData, onNavigateToOrg, t])
|
||||
|
||||
const handleSessionConfigChange = useCallback((taskId: string, sessionMode: string, sessionCompanyProfile?: string, orgId?: string) => {
|
||||
onSessionConfigChange?.(taskId, sessionMode, sessionCompanyProfile, orgId)
|
||||
@@ -1094,7 +1099,7 @@ export function WorkspacePage({
|
||||
}, [handleOpenWorkItemSession, onOpenExecutionPanel])
|
||||
|
||||
const isSecretary = effectiveView.kind === 'secretary'
|
||||
const channelName = isSecretary ? 'Secretary' : activeSession ? activeSession.title : 'Activity'
|
||||
const channelName = isSecretary ? t('workspace.channel.secretary') : activeSession ? activeSession.title : t('workspace.channel.activity')
|
||||
|
||||
return (
|
||||
<div className={`workspace-page${panelState === 'maximized' ? ' panel-maximized' : ''}`}>
|
||||
@@ -1137,7 +1142,7 @@ export function WorkspacePage({
|
||||
)}
|
||||
{isCompanyMode && !boardStore.activeBoard ? (
|
||||
<div className="kanban-empty-state">
|
||||
<p>Select a Runtime Session on the left to view its Work Item board.</p>
|
||||
<p>{t('workspace.selectRuntimeSession')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -1156,7 +1161,7 @@ export function WorkspacePage({
|
||||
/>
|
||||
{isCompanyMode && boardStore.activeBoard && boardStore.tasks.filter(t => t.boardId === boardStore.activeBoardId).length === 0 && (
|
||||
<div className="kanban-empty-state kanban-empty-state-inline">
|
||||
<p>No work items yet — start delegation to populate this board.</p>
|
||||
<p>{t('workspace.noWorkItems')}</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user