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:
@@ -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