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:
小东
2026-07-16 09:20:40 +08:00
parent eb934cf2bd
commit cb6b8ed6e1
32 changed files with 2562 additions and 446 deletions
+15 -2
View File
@@ -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")
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>
+73 -49
View File
@@ -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">&#x21C4;</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')}>&#x21C4;</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) }}
>
&#x25B6;
@@ -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">&#x1F464;</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>
+4 -1
View File
@@ -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>
)}
</>