Compare commits

9 Commits

Author SHA1 Message Date
Iqbal
d00561f75d Keep the sign-out button visible with a long name or email
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 12:25:50 +07:00
Iqbal
8d745ad96e Fit the knowledge graph in the viewport on short screens
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 12:21:33 +07:00
Iqbal
bcd747721d Show who created each schedule and start with your own
Org admins see every Express schedule in the organization, so a shared
organization mixed everyone's schedules together. The Schedule page now
shows "Created by …" on each one and, when others' schedules are listed,
switches between Mine and Everyone in the organization.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 12:18:28 +07:00
Iqbal
bdae539e9e Show only the knowledge base and content in the knowledge graph
The graph now represents the agent's Brain: its documents and the
content made from them. Work in progress, failed runs, research
suggestions and schedules are status, so they are left out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 11:51:04 +07:00
Iqbal
643c3c39f9 Remove the Brain panel from the Brain and AI page
The page is back to the chat and the plan side by side. With nothing to
tick, new schedules always use the whole Brain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 11:50:39 +07:00
Iqbal
adbcfcc7ba Keep the knowledge graph when one schedule fails to load
One failing per-schedule request no longer blanks the whole graph, and
the graph shows the engine's own error instead of always saying the
engine cannot be reached.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 10:19:56 +07:00
Iqbal
278c66897a Make the sidebar collapsible and let the knowledge graph go full screen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 10:14:34 +07:00
Iqbal
bcd7964e0b Say when a research run found no new sources
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 10:14:34 +07:00
43a977017b Merge pull request 'show-run-errors-and-reprocess' (#2) from show-run-errors-and-reprocess into main
Reviewed-on: #2
2026-10-28 09:40:05 +00:00
14 changed files with 352 additions and 166 deletions

View File

@@ -128,6 +128,12 @@ function OrgSwitcher() {
) )
} }
// Remembered per browser; storage can throw in private windows, so it falls back to expanded.
const COLLAPSED_KEY = 'sidebar-collapsed'
function readCollapsed() {
try { return localStorage.getItem(COLLAPSED_KEY) === '1' } catch { return false }
}
export function AppLayout() { export function AppLayout() {
useLang() useLang()
const [help, setHelp] = useState(false) const [help, setHelp] = useState(false)
@@ -138,16 +144,22 @@ export function AppLayout() {
// Nav groups stay closed unless they hold the current page; a click on the label overrides that until the next route. // Nav groups stay closed unless they hold the current page; a click on the label overrides that until the next route.
const [toggled, setToggled] = useState<Partial<Record<Key, boolean>>>({}) const [toggled, setToggled] = useState<Partial<Record<Key, boolean>>>({})
useEffect(() => setToggled({}), [pathname]) useEffect(() => setToggled({}), [pathname])
const [collapsed, setCollapsed] = useState(readCollapsed)
const toggleCollapsed = () => setCollapsed(c => {
try { localStorage.setItem(COLLAPSED_KEY, c ? '0' : '1') } catch { /* keep it for this visit only */ }
return !c
})
const name = user?.fullName || user?.primaryEmailAddress?.emailAddress || '-' const name = user?.fullName || user?.primaryEmailAddress?.emailAddress || '-'
return ( return (
<div className="app"> <div className={collapsed ? 'app sidebar-collapsed' : 'app'}>
<aside className="sidebar"> <aside className="sidebar">
<NavLink to="/production"><Brand /></NavLink> <NavLink to="/production"><Brand /></NavLink>
<OrgSwitcher /> <OrgSwitcher />
<nav aria-label={t('layout.mainNav')}> <nav aria-label={t('layout.mainNav')}>
{NAV_GROUPS.map(g => { {NAV_GROUPS.map(g => {
const open = toggled[g.label] ?? g.items.some(n => pathname.startsWith(n.to)) // The icon rail has no group labels, so every item shows.
const open = collapsed || (toggled[g.label] ?? g.items.some(n => pathname.startsWith(n.to)))
return [ return [
<button key={g.label} type="button" className="nav-label" aria-expanded={open} aria-controls={`nav-${g.label}`} <button key={g.label} type="button" className="nav-label" aria-expanded={open} aria-controls={`nav-${g.label}`}
onClick={() => setToggled(s => ({ ...s, [g.label]: !open }))}> onClick={() => setToggled(s => ({ ...s, [g.label]: !open }))}>
@@ -155,28 +167,36 @@ export function AppLayout() {
</button>, </button>,
<div key={`${g.label}-items`} id={`nav-${g.label}`} className={open ? 'nav-items' : 'nav-items closed'}> <div key={`${g.label}-items`} id={`nav-${g.label}`} className={open ? 'nav-items' : 'nav-items closed'}>
{g.items.map(n => ( {g.items.map(n => (
<NavLink key={n.to} to={n.to} className={({ isActive }) => (isActive ? 'active' : undefined)}> <NavLink key={n.to} to={n.to} className={({ isActive }) => (isActive ? 'active' : undefined)}
<Icon name={n.icon} />{t(n.label)} title={collapsed ? t(n.label) : undefined}>
<Icon name={n.icon} /><span className="nav-text">{t(n.label)}</span>
</NavLink> </NavLink>
))} ))}
</div>, </div>,
] ]
})} })}
</nav> </nav>
<div className="sidebar-help"> {collapsed ? (
<button type="button" className="icon-btn sidebar-help-icon" title={t('help.open')} aria-label={t('help.open')}
onClick={() => setHelp(true)}><Icon name="info" /></button>
) : <div className="sidebar-help">
<Icon name="info" /> <Icon name="info" />
<b>{t('help.boxTitle')}</b> <b>{t('help.boxTitle')}</b>
<p>{t('help.boxBody')}</p> <p>{t('help.boxBody')}</p>
<button type="button" onClick={() => setHelp(true)}>{t('help.open')} <Icon name="arrow-right" /></button> <button type="button" onClick={() => setHelp(true)}>{t('help.open')} <Icon name="arrow-right" /></button>
</div> </div>}
<div className="profile"> <div className="profile">
<span className="avatar">{name.charAt(0).toUpperCase()}</span> <span className="avatar" title={collapsed ? name : undefined}>{name.charAt(0).toUpperCase()}</span>
<div><b>{name}</b><small>{user?.primaryEmailAddress?.emailAddress}</small></div> <div><b title={name}>{name}</b><small title={user?.primaryEmailAddress?.emailAddress}>{user?.primaryEmailAddress?.emailAddress}</small></div>
<button className="icon-btn" aria-label={t('layout.signOut')} onClick={() => signOut({ redirectUrl: '/' })}><Icon name="logout" /></button> <button className="icon-btn" aria-label={t('layout.signOut')} onClick={() => signOut({ redirectUrl: '/' })}><Icon name="logout" /></button>
</div> </div>
</aside> </aside>
<div className="workspace-main"> <div className="workspace-main">
<header className="topbar"> <header className="topbar">
<button type="button" className="icon-btn sidebar-toggle" aria-pressed={collapsed}
aria-label={t(collapsed ? 'layout.expandSidebar' : 'layout.collapseSidebar')}
title={t(collapsed ? 'layout.expandSidebar' : 'layout.collapseSidebar')}
onClick={toggleCollapsed}><Icon name="sidebar" /></button>
<div className="breadcrumb">{t('layout.breadcrumb')} <span>/</span> <b>{current ? t(current.label) : ''}</b></div> <div className="breadcrumb">{t('layout.breadcrumb')} <span>/</span> <b>{current ? t(current.label) : ''}</b></div>
<div className="topbar-right"> <div className="topbar-right">
<span className="demo-indicator"><i></i> {t('layout.engine')}</span> <span className="demo-indicator"><i></i> {t('layout.engine')}</span>

View File

@@ -18,8 +18,12 @@ const paths: Record<string, string> = {
info: '<circle cx="12" cy="12" r="9"/><path d="M12 11v6M12 7v.2"/>', info: '<circle cx="12" cy="12" r="9"/><path d="M12 11v6M12 7v.2"/>',
check: '<path d="m5 12 4 4L19 6"/>', check: '<path d="m5 12 4 4L19 6"/>',
'chevron-down': '<path d="m6 9 6 6 6-6"/>', 'chevron-down': '<path d="m6 9 6 6 6-6"/>',
expand: '<path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/>',
shrink: '<path d="M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5"/>',
sidebar: '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M9 4v16"/>',
'chevron-updown': '<path d="m8 9 4-4 4 4M8 15l4 4 4-4"/>', 'chevron-updown': '<path d="m8 9 4-4 4 4M8 15l4 4 4-4"/>',
logout:'<path d="M10 4H4v16h6M10 12h11m-4-4 4 4-4 4"/>', logout:'<path d="M10 4H4v16h6M10 12h11m-4-4 4 4-4 4"/>',
user: '<circle cx="12" cy="8" r="4"/><path d="M4 21c1-4 4-6 8-6s7 2 8 6"/>',
plus: '<path d="M12 4v16M4 12h16"/>', plus: '<path d="M12 4v16M4 12h16"/>',
trash: '<path d="M3 6h18M9 3h6l1 3M6 6l1 15h10l1-15M10 10v7M14 10v7"/>', trash: '<path d="M3 6h18M9 3h6l1 3M6 6l1 15h10l1-15M10 10v7M14 10v7"/>',
video: '<rect x="3" y="5" width="18" height="14" rx="3"/><path d="m10 9 5 3-5 3z"/>', video: '<rect x="3" y="5" width="18" height="14" rx="3"/><path d="m10 9 5 3-5 3z"/>',

View File

@@ -1,31 +1,30 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import type { Source } from '../api/engine' import type { Source } from '../api/engine'
import { buildGraph, HEIGHT, WIDTH } from './graph' import { buildGraph, HEIGHT, WIDTH } from './graph'
import type { FoundSource, Material } from './results' import type { Material } from './results'
import type { Schedule } from './schedules'
const doc = (docId: number, title: string) => ({ docId, title, originalName: `${title}.pdf` }) as Source const doc = (docId: number, title: string, status = 'completed') => ({ docId, title, originalName: `${title}.pdf`, status }) as Source
const material = (id: number, status: Material['status'], sources: string[]) => const material = (id: number, status: Material['status'], sources: string[]) =>
({ id, agentId: 7, kind: 'summary', status, planTitle: `Lesson ${id}`, sources }) as Material ({ id, agentId: 7, kind: 'summary', status, planTitle: `Lesson ${id}`, sources }) as Material
const schedule = { agent: { id: 7, name: 'Express · 2 summary per week' } } as Schedule
describe('buildGraph', () => { describe('buildGraph', () => {
const data = { const data = {
docs: [doc(1, 'Onboarding'), doc(2, 'Safety')], docs: [doc(1, 'Onboarding'), doc(2, 'Safety'), doc(3, 'Broken', 'failed')],
materials: [material(10, 'accepted', ['Onboarding.pdf', 'Safety']), material(11, 'generating', [])], materials: [
sources: [{ id: 5, agentId: 7, title: 'Article', documentId: 2 } as FoundSource], material(10, 'accepted', ['Onboarding.pdf', 'Safety']), material(11, 'generating', []),
schedules: [schedule], material(12, 'failed', ['Safety']), material(13, 'ready', ['Safety']),
],
} }
const graph = buildGraph(data, c => c) const graph = buildGraph(data, c => c)
it('puts results in progress in their own cluster', () => { it('holds only the knowledge base and the content made from it', () => {
expect(graph.nodes.find(n => n.id === 'mat-7-10')?.cluster).toBe('made') expect(graph.nodes.filter(n => !n.hub).map(n => `${n.cluster}:${n.id}`).sort())
expect(graph.nodes.find(n => n.id === 'mat-7-11')?.cluster).toBe('working') .toEqual(['made:mat-7-10', 'made:mat-7-13', 'sources:doc-1', 'sources:doc-2'])
}) })
it('links results to their sources and schedule, and found sources to their document', () => { it('links content to its sources', () => {
const cross = graph.links.filter(l => !l.hub).map(l => `${l.source}>${l.target}`) const cross = graph.links.filter(l => !l.hub).map(l => `${l.source}>${l.target}`)
expect(cross).toEqual(expect.arrayContaining(['mat-7-10>doc-1', 'mat-7-10>doc-2', 'mat-7-10>agent-7', 'found-7-5>doc-2'])) expect(cross.sort()).toEqual(['mat-7-10>doc-1', 'mat-7-10>doc-2', 'mat-7-13>doc-2'])
}) })
it('lays out the same data the same way, inside the canvas', () => { it('lays out the same data the same way, inside the canvas', () => {

View File

@@ -1,13 +1,13 @@
// The Production knowledge graph: every Brain document, result, found source and schedule as a node, // The Production knowledge graph: what the agent's Brain holds, its knowledge base (the documents)
// grouped around one hub per cluster and linked where the engine says they are related (a result's // and the content made from it, grouped around one hub per cluster and linked where a piece of
// sources, the schedule that made it, the document a found source became). Laid out once with a // content names a document as its source. Work in progress, failed runs and schedules are status,
// small seeded force simulation, so the same data always draws the same picture. // not knowledge, so they stay out. Laid out once with a small seeded force simulation, so the same
// data always draws the same picture.
import type { Source } from '../api/engine' import type { Source } from '../api/engine'
import { isWorking, type FoundSource, type Material } from './results' import { isWorking, type Material } from './results'
import type { Schedule } from './schedules'
export type Cluster = 'sources' | 'made' | 'working' | 'research' | 'schedules' export type Cluster = 'sources' | 'made'
export const CLUSTERS: Cluster[] = ['sources', 'made', 'working', 'research', 'schedules'] export const CLUSTERS: Cluster[] = ['sources', 'made']
export interface GraphNode { export interface GraphNode {
id: string id: string
@@ -27,13 +27,16 @@ export const HEIGHT = 640
// Where each cluster settles, around the middle of the canvas. // Where each cluster settles, around the middle of the canvas.
const ANCHOR: Record<Cluster, [number, number]> = { const ANCHOR: Record<Cluster, [number, number]> = {
sources: [560, 360], made: [450, 430], working: [330, 300], research: [440, 200], schedules: [700, 230], sources: [580, 320], made: [420, 320],
} }
const norm = (s: string) => s.trim().toLowerCase() const norm = (s: string) => s.trim().toLowerCase()
// Content still being made, failed or turned down is not part of the Brain.
const inBrain = (m: Material) => !isWorking(m) && m.status !== 'failed' && m.status !== 'rejected'
export function buildGraph( export function buildGraph(
{ docs, materials, sources, schedules }: { docs: Source[]; materials: Material[]; sources: FoundSource[]; schedules: Schedule[] }, { docs, materials }: { docs: Source[]; materials: Material[] },
hubLabel: (c: Cluster) => string, hubLabel: (c: Cluster) => string,
): Graph { ): Graph {
const nodes: GraphNode[] = CLUSTERS.map(c => ({ id: `hub-${c}`, cluster: c, label: hubLabel(c), hub: true, x: 0, y: 0 })) const nodes: GraphNode[] = CLUSTERS.map(c => ({ id: `hub-${c}`, cluster: c, label: hubLabel(c), hub: true, x: 0, y: 0 }))
@@ -46,30 +49,19 @@ export function buildGraph(
// Results name their sources by title, so documents are found by title or file name. // Results name their sources by title, so documents are found by title or file name.
const docByName = new Map<string, string>() const docByName = new Map<string, string>()
for (const d of docs) { for (const d of docs.filter(d => d.status !== 'failed' && d.knowledge !== 'failed')) {
add(`doc-${d.docId}`, 'sources', d.title) add(`doc-${d.docId}`, 'sources', d.title)
docByName.set(norm(d.title), `doc-${d.docId}`) docByName.set(norm(d.title), `doc-${d.docId}`)
docByName.set(norm(d.originalName), `doc-${d.docId}`) docByName.set(norm(d.originalName), `doc-${d.docId}`)
} }
for (const s of schedules) add(`agent-${s.agent.id}`, 'schedules', s.agent.name.split(' · ').slice(1).join(' · ') || s.agent.name) for (const m of materials.filter(inBrain)) {
const agents = new Set(schedules.map(s => `agent-${s.agent.id}`))
for (const m of materials) {
const id = `${m.kind === 'video' ? 'video' : 'mat'}-${m.agentId}-${m.id}` const id = `${m.kind === 'video' ? 'video' : 'mat'}-${m.agentId}-${m.id}`
add(id, isWorking(m) ? 'working' : 'made', m.planTitle ?? m.kind) add(id, 'made', m.planTitle ?? m.kind)
if (agents.has(`agent-${m.agentId}`)) link(id, `agent-${m.agentId}`)
for (const title of new Set(m.sources)) { for (const title of new Set(m.sources)) {
const doc = docByName.get(norm(title)) const doc = docByName.get(norm(title))
if (doc) link(id, doc) if (doc) link(id, doc)
} }
} }
for (const s of sources) {
const id = `found-${s.agentId}-${s.id}`
add(id, 'research', s.title)
if (agents.has(`agent-${s.agentId}`)) link(id, `agent-${s.agentId}`)
if (s.documentId != null && docs.some(d => d.docId === s.documentId)) link(id, `doc-${s.documentId}`)
}
layout(nodes, links) layout(nodes, links)
return { nodes, links, width: WIDTH, height: HEIGHT } return { nodes, links, width: WIDTH, height: HEIGHT }
} }

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { correctIndex, questionsOf, toMarkdown, withPlanItem, type Material } from './results' import { correctIndex, keepSettled, questionsOf, toMarkdown, withPlanItem, type Material } from './results'
import { sentencesOf, scriptLang } from '../components/AudioPlayer' import { sentencesOf, scriptLang } from '../components/AudioPlayer'
describe('correctIndex', () => { describe('correctIndex', () => {
@@ -65,3 +65,20 @@ describe('withPlanItem', () => {
expect(withPlanItem('preparing', undefined)).toBe('preparing') expect(withPlanItem('preparing', undefined)).toBe('preparing')
}) })
}) })
describe('keepSettled', () => {
const ok = (v: number[]): PromiseSettledResult<number[]> => ({ status: 'fulfilled', value: v })
const bad = (message: string): PromiseSettledResult<number[]> => ({ status: 'rejected', reason: new Error(message) })
it('leaves out a schedule that failed', () => {
expect(keepSettled([ok([1, 2]), bad('404'), ok([3])])).toEqual([1, 2, 3])
})
it('fails when every schedule failed', () => {
expect(() => keepSettled([bad('500'), bad('502')])).toThrow('500')
})
it('is empty without schedules', () => {
expect(keepSettled([])).toEqual([])
})
})

View File

@@ -172,14 +172,24 @@ async function sourcesOf({ agent, rule }: Schedule): Promise<FoundSource[]> {
})) }))
} }
// One schedule the engine cannot answer for (e.g. an archived agent) leaves its results out
// instead of failing them all; only when every schedule fails is that an error.
export function keepSettled<T>(results: PromiseSettledResult<T[]>[]): T[] {
const kept = results.flatMap(r => (r.status === 'fulfilled' ? r.value : []))
const failed = results.filter(r => r.status === 'rejected')
if (failed.length) console.warn(`${failed.length} of ${results.length} schedules could not be loaded`, failed.map(r => r.reason))
if (results.length && failed.length === results.length) throw failed[0].reason
return kept
}
export async function listResults(): Promise<{ materials: Material[]; sources: FoundSource[] }> { export async function listResults(): Promise<{ materials: Material[]; sources: FoundSource[] }> {
const schedules = await expressAgents({ archived: true }) const schedules = await expressAgents({ archived: true })
const [materials, sources] = await Promise.all([ const [materials, sources] = await Promise.all([
Promise.all(schedules.filter(s => s.rule.type === 'content').map(materialsOf)), Promise.allSettled(schedules.filter(s => s.rule.type === 'content').map(materialsOf)),
Promise.all(schedules.filter(s => s.rule.type === 'research').map(sourcesOf)), Promise.allSettled(schedules.filter(s => s.rule.type === 'research').map(sourcesOf)),
]) ])
const newest = <T extends { createdAt: string }>(a: T, b: T) => b.createdAt.localeCompare(a.createdAt) const newest = <T extends { createdAt: string }>(a: T, b: T) => b.createdAt.localeCompare(a.createdAt)
return { materials: materials.flat().sort(newest), sources: sources.flat().sort(newest) } return { materials: keepSettled(materials).sort(newest), sources: keepSettled(sources).sort(newest) }
} }
const draftContent = (m: Material, id: number) => const draftContent = (m: Material, id: number) =>

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import type { ContentRule, ResearchRule } from './parse' import type { ContentRule, ResearchRule } from './parse'
import { budgetFor, canSchedule, configFor, needsTwoSources, productionError, ruleOf, VIDEO, type ExpressAgent } from './schedules' import { budgetFor, canSchedule, configFor, isMine, needsTwoSources, productionError, ruleOf, VIDEO, type ExpressAgent } from './schedules'
const rule = (kind: ContentRule['kind'], count = 2, per: ContentRule['per'] = 'week', topic = 'onboarding', language: ContentRule['language'] = 'English'): ContentRule => const rule = (kind: ContentRule['kind'], count = 2, per: ContentRule['per'] = 'week', topic = 'onboarding', language: ContentRule['language'] = 'English'): ContentRule =>
({ type: 'content', kind, count, per, topic, language }) ({ type: 'content', kind, count, per, topic, language })
@@ -9,7 +9,7 @@ const rule = (kind: ContentRule['kind'], count = 2, per: ContentRule['per'] = 'w
function agentFrom(config: ReturnType<typeof configFor>): ExpressAgent { function agentFrom(config: ReturnType<typeof configFor>): ExpressAgent {
const c = config as Record<string, unknown> const c = config as Record<string, unknown>
return { return {
id: 1, status: 'active', next_run_at: null, created_at: '2026-10-01T00:00:00Z', auto_approve_sources: false, id: 1, status: 'active', next_run_at: null, created_at: '2026-10-01T00:00:00Z', created_by_user_id: 7, auto_approve_sources: false,
name: c.name as string, goal: c.goal as string, instructions: c.instructions as string, name: c.name as string, goal: c.goal as string, instructions: c.instructions as string,
enabled_tools: c.enabled_tools as string[], enabled_tools: c.enabled_tools as string[],
material_outputs: (c.material_outputs as ExpressAgent['material_outputs']) ?? [], material_outputs: (c.material_outputs as ExpressAgent['material_outputs']) ?? [],
@@ -122,3 +122,20 @@ describe('production sources', () => {
expect(productionError('Something else')).toBe('Something else') expect(productionError('Something else')).toBe('Something else')
}) })
}) })
describe('isMine', () => {
const owners = { me: 7, names: new Map<number, string>() }
const schedule = (by: number | null) => {
const agent = { ...agentFrom(configFor(rule('summary'))), created_by_user_id: by }
return { agent, rule: ruleOf(agent), active: true }
}
it('keeps only the viewer’s own schedules', () => {
expect(isMine(schedule(7), owners)).toBe(true)
expect(isMine(schedule(8), owners)).toBe(false)
})
it('counts a schedule whose creator was removed as someone else’s', () => {
expect(isMine(schedule(null), owners)).toBe(false)
})
})

View File

@@ -41,6 +41,7 @@ export interface ExpressAgent extends Agent {
run_budget: number run_budget: number
monthly_budget: number monthly_budget: number
created_at: string created_at: string
created_by_user_id: number | null
} }
export interface Schedule { export interface Schedule {
@@ -107,6 +108,23 @@ export async function expressAgents({ archived = false } = {}): Promise<Schedule
export const listSchedules = () => expressAgents() export const listSchedules = () => expressAgents()
// An organization admin sees every schedule in the organization, a member only their own (and any
// shared with them), so the Schedule page says who made each one and starts with the viewer's own.
export interface Owners { me: number; names: Map<number, string> }
/** The viewer's engine user id and the organization roster's names. The roster is admin-only;
* a member gets no names, which is fine: a member's list holds their own schedules. */
export async function scheduleOwners(): Promise<Owners> {
const [me, roster] = await Promise.all([
request<{ id: number }>('/auth/me'),
request<{ members: { user_id: number; name: string | null; email: string | null }[] }>('/organizations/current/dashboard/members')
.catch(() => ({ members: [] })),
])
return { me: me.id, names: new Map(roster.members.map(m => [m.user_id, m.name || m.email || `#${m.user_id}`])) }
}
export const isMine = (s: Schedule, owners: Owners) => s.agent.created_by_user_id === owners.me
/** Schedules made of one engine run per slot (research, video), rather than weekly production plans. */ /** Schedules made of one engine run per slot (research, video), rather than weekly production plans. */
export const isRunSchedule = (rule: Rule) => rule.type === 'research' || rule.kind === 'video' export const isRunSchedule = (rule: Rule) => rule.type === 'research' || rule.kind === 'video'
@@ -365,6 +383,8 @@ export interface RunSummary {
safe_error_code: string | null safe_error_code: string | null
budget_spent: number | null budget_spent: number | null
has_readiness_issues: boolean has_readiness_issues: boolean
/** Research runs: what came of the sources found, e.g. no_new_sources when nothing was kept. */
source_outcome?: string | null
created_at: string created_at: string
finished_at: string | null finished_at: string | null
} }

View File

@@ -23,6 +23,8 @@ const en = {
'layout.toStudio': 'Switch to Learning Lab', 'layout.toStudio': 'Switch to Learning Lab',
'layout.footer': 'A workspace for knowledge that keeps growing.', 'layout.footer': 'A workspace for knowledge that keeps growing.',
'layout.mainNav': 'Main navigation', 'layout.mainNav': 'Main navigation',
'layout.collapseSidebar': 'Collapse sidebar',
'layout.expandSidebar': 'Expand sidebar',
'layout.language': 'Language', 'layout.language': 'Language',
// Auth // Auth
@@ -71,13 +73,12 @@ const en = {
'prod.newContent': 'Create new content', 'prod.newContent': 'Create new content',
'prod.graphTitle': 'Knowledge graph', 'prod.graphTitle': 'Knowledge graph',
'prod.clickNode': 'Click a node', 'prod.clickNode': 'Click a node',
'prod.fullscreen': 'Full screen',
'prod.exitFullscreen': 'Exit full screen',
'prod.loadingGraph': 'Drawing the knowledge graph…', 'prod.loadingGraph': 'Drawing the knowledge graph…',
'prod.graphLabel': 'Knowledge graph of {nodes} nodes', 'prod.graphLabel': 'Knowledge graph of {nodes} nodes',
'prod.hub.sources': 'Sources', 'prod.hub.sources': 'Knowledge base',
'prod.hub.made': 'Made', 'prod.hub.made': 'Content',
'prod.hub.working': 'In progress',
'prod.hub.research': 'Research',
'prod.hub.schedules': 'Repeats',
'prod.nodes': 'Nodes', 'prod.nodes': 'Nodes',
'prod.nodesSub': 'in this Brain', 'prod.nodesSub': 'in this Brain',
'prod.links': 'Links', 'prod.links': 'Links',
@@ -205,6 +206,13 @@ const en = {
'sch.loading': 'Loading schedules…', 'sch.loading': 'Loading schedules…',
'sch.loadFailed': 'Could not load schedules', 'sch.loadFailed': 'Could not load schedules',
'sch.empty': 'No schedules yet', 'sch.empty': 'No schedules yet',
'sch.mine': 'Mine',
'sch.all': 'Everyone in the organization',
'sch.emptyMine': 'You have no schedules yet',
'sch.emptyMineCopy': 'Other people in this organization have schedules; see them under “Everyone in the organization”.',
'sch.byYou': 'Created by you',
'sch.by': 'Created by {name}',
'sch.byUnknown': 'Created by someone else',
'sch.emptyCopy': 'Give an instruction like “2 summaries per day about leadership” to start.', 'sch.emptyCopy': 'Give an instruction like “2 summaries per day about leadership” to start.',
'sch.nextResearch': 'Next research: {when}', 'sch.nextResearch': 'Next research: {when}',
'sch.paused': 'Paused', 'sch.paused': 'Paused',
@@ -262,6 +270,7 @@ const en = {
'runErr.agent_not_active': 'The schedule is paused or deleted.', 'runErr.agent_not_active': 'The schedule is paused or deleted.',
'runErr.run_access_revoked': 'You no longer have permission to run this schedule.', 'runErr.run_access_revoked': 'You no longer have permission to run this schedule.',
'runErr.required_tool_unavailable': 'A tool this schedule needs is turned off.', 'runErr.required_tool_unavailable': 'A tool this schedule needs is turned off.',
'sch.noNewSources': 'No new sources found. A more specific topic may help.',
'sch.blockedBy': 'Blocking material:', 'sch.blockedBy': 'Blocking material:',
'sch.blockedNone': 'All material is ready now. Try again.', 'sch.blockedNone': 'All material is ready now. Try again.',
'sch.fixInStudio': 'Open in Learning Studio', 'sch.fixInStudio': 'Open in Learning Studio',
@@ -528,13 +537,12 @@ const id: Record<Key, string> = {
'prod.newContent': 'Buat konten baru', 'prod.newContent': 'Buat konten baru',
'prod.graphTitle': 'Knowledge graph', 'prod.graphTitle': 'Knowledge graph',
'prod.clickNode': 'Klik sebuah node', 'prod.clickNode': 'Klik sebuah node',
'prod.fullscreen': 'Layar penuh',
'prod.exitFullscreen': 'Keluar layar penuh',
'prod.loadingGraph': 'Menggambar knowledge graph…', 'prod.loadingGraph': 'Menggambar knowledge graph…',
'prod.graphLabel': 'Knowledge graph berisi {nodes} node', 'prod.graphLabel': 'Knowledge graph berisi {nodes} node',
'prod.hub.sources': 'Sumber', 'prod.hub.sources': 'Basis pengetahuan',
'prod.hub.made': 'Dibuat', 'prod.hub.made': 'Konten',
'prod.hub.working': 'Sedang dibuat',
'prod.hub.research': 'Riset',
'prod.hub.schedules': 'Berulang',
'prod.nodes': 'Node', 'prod.nodes': 'Node',
'prod.nodesSub': 'di Otak ini', 'prod.nodesSub': 'di Otak ini',
'prod.links': 'Tautan', 'prod.links': 'Tautan',
@@ -561,6 +569,8 @@ const id: Record<Key, string> = {
'layout.toStudio': 'Beralih ke Learning Lab', 'layout.toStudio': 'Beralih ke Learning Lab',
'layout.footer': 'Ruang kerja untuk pengetahuan yang terus berkembang.', 'layout.footer': 'Ruang kerja untuk pengetahuan yang terus berkembang.',
'layout.mainNav': 'Navigasi utama', 'layout.mainNav': 'Navigasi utama',
'layout.collapseSidebar': 'Ciutkan sidebar',
'layout.expandSidebar': 'Lebarkan sidebar',
'layout.language': 'Bahasa', 'layout.language': 'Bahasa',
'auth.loading': 'Memuat…', 'auth.loading': 'Memuat…',
@@ -694,6 +704,13 @@ const id: Record<Key, string> = {
'sch.loading': 'Memuat jadwal…', 'sch.loading': 'Memuat jadwal…',
'sch.loadFailed': 'Jadwal tidak dapat dimuat', 'sch.loadFailed': 'Jadwal tidak dapat dimuat',
'sch.empty': 'Belum ada jadwal', 'sch.empty': 'Belum ada jadwal',
'sch.mine': 'Punya saya',
'sch.all': 'Semua di organisasi',
'sch.emptyMine': 'Anda belum punya jadwal',
'sch.emptyMineCopy': 'Orang lain di organisasi ini punya jadwal; lihat di “Semua di organisasi”.',
'sch.byYou': 'Dibuat oleh Anda',
'sch.by': 'Dibuat oleh {name}',
'sch.byUnknown': 'Dibuat oleh orang lain',
'sch.emptyCopy': 'Beri perintah seperti “2 ringkasan per hari soal leadership” untuk mulai.', 'sch.emptyCopy': 'Beri perintah seperti “2 ringkasan per hari soal leadership” untuk mulai.',
'sch.nextResearch': 'Riset berikutnya: {when}', 'sch.nextResearch': 'Riset berikutnya: {when}',
'sch.paused': 'Dijeda', 'sch.paused': 'Dijeda',
@@ -751,6 +768,7 @@ const id: Record<Key, string> = {
'runErr.agent_not_active': 'Jadwal sedang dijeda atau sudah dihapus.', 'runErr.agent_not_active': 'Jadwal sedang dijeda atau sudah dihapus.',
'runErr.run_access_revoked': 'Kamu tidak lagi punya izin menjalankan jadwal ini.', 'runErr.run_access_revoked': 'Kamu tidak lagi punya izin menjalankan jadwal ini.',
'runErr.required_tool_unavailable': 'Alat yang dibutuhkan jadwal ini sedang nonaktif.', 'runErr.required_tool_unavailable': 'Alat yang dibutuhkan jadwal ini sedang nonaktif.',
'sch.noNewSources': 'Tidak ada source baru yang ditemukan. Topik yang lebih spesifik bisa membantu.',
'sch.blockedBy': 'Materi yang menahan:', 'sch.blockedBy': 'Materi yang menahan:',
'sch.blockedNone': 'Semua materi sudah siap. Coba lagi.', 'sch.blockedNone': 'Semua materi sudah siap. Coba lagi.',
'sch.fixInStudio': 'Buka di Learning Studio', 'sch.fixInStudio': 'Buka di Learning Studio',

View File

@@ -3,7 +3,7 @@ import { Link } from 'react-router-dom'
import { useQuery, useQueryClient } from '@tanstack/react-query' import { useQuery, useQueryClient } from '@tanstack/react-query'
import { Icon, ruleIcon } from '../components/Icon' import { Icon, ruleIcon } from '../components/Icon'
import { PageHead } from '../components/Page' import { PageHead } from '../components/Page'
import { BrainPanel, choosable, useDocuments } from './Brain' import { choosable, useDocuments } from './Brain'
import { import {
dayName, describeRule, kindLabel, KINDS, LANGUAGES, languageLabel, monthNote, videoCadence, weeklyTarget, dayName, describeRule, kindLabel, KINDS, LANGUAGES, languageLabel, monthNote, videoCadence, weeklyTarget,
type ContentKind, type ContentRule, type OutputLanguage, type ResearchRule, type Rule, type ContentKind, type ContentRule, type OutputLanguage, type ResearchRule, type Rule,
@@ -36,14 +36,7 @@ export function InstructionPage() {
const [agent, setAgent] = useState<AgentState>(EMPTY) const [agent, setAgent] = useState<AgentState>(EMPTY)
const [messages, setMessages] = useState<Message[]>([]) const [messages, setMessages] = useState<Message[]>([])
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
// Brain material the schedules are made from; none chosen means the whole Brain.
const [selected, setSelected] = useState<Set<number>>(new Set())
const docs = useDocuments() const docs = useDocuments()
// A chosen document that was deleted, or failed since, no longer counts.
const chosenDocs = choosable(docs.data).filter(d => selected.has(d.docId))
const chosen = chosenDocs.map(d => d.docId)
// How the chat and the plan name the chosen material: by title when there is one.
const sources = chosenDocs.length === 1 ? `“${chosenDocs[0].title}”` : chosenDocs.length ? t('brainPanel.nChosen', { n: chosenDocs.length }) : null
const [phase, setPhase] = useState<Phase>('idle') const [phase, setPhase] = useState<Phase>('idle')
// The live question whose typing has finished, so its reply options can show. // The live question whose typing has finished, so its reply options can show.
const [typedLive, setTypedLive] = useState('') const [typedLive, setTypedLive] = useState('')
@@ -55,19 +48,16 @@ export function InstructionPage() {
const later = (fn: () => void, ms: number) => { timers.current.push(window.setTimeout(fn, ms)) } const later = (fn: () => void, ms: number) => { timers.current.push(window.setTimeout(fn, ms)) }
useEffect(() => () => timers.current.forEach(clearTimeout), []) useEffect(() => () => timers.current.forEach(clearTimeout), [])
// The agent sees the current choice, so ticking material in the Brain panel updates its question and plan. const question = nextQuestion(agent)
const view: AgentState = { ...agent, sources }
const question = nextQuestion(view)
const ready = agent.items.filter(i => canSchedule(i.rule)) const ready = agent.items.filter(i => canSchedule(i.rule))
// Without a choice the whole Brain is attached, so every ready document counts. // The whole Brain is attached, so every ready document counts.
const sourceCount = chosen.length || choosable(docs.data).length const tooFewSources = choosable(docs.data).length < MIN_PRODUCTION_SOURCES && ready.some(i => needsTwoSources(i.rule))
const tooFewSources = sourceCount < MIN_PRODUCTION_SOURCES && ready.some(i => needsTwoSources(i.rule))
const hasContent = agent.items.some(i => i.rule.type === 'content') const hasContent = agent.items.some(i => i.rule.type === 'content')
// The agent's current turn: its question, or what it is waiting for. It stays live, so an edit in the plan updates it. // The agent's current turn: its question, or what it is waiting for. It stays live, so an edit in the plan updates it.
const live = inLang(lang, () => saving const live = inLang(lang, () => saving
? { text: t('agent.approving'), options: [] as string[] } ? { text: t('agent.approving'), options: [] as string[] }
: question : question
? ask(question, view) ? ask(question, agent)
: agent.items.length : agent.items.length
? { ? {
text: hasContent && agent.language text: hasContent && agent.language
@@ -89,7 +79,7 @@ export function InstructionPage() {
// A picked option is in the conversation's language already; typed text may switch it. // A picked option is in the conversation's language already; typed text may switch it.
const replyLang = live.options.includes(input) ? lang : chatLangOf(input) ?? lang const replyLang = live.options.includes(input) ? lang : chatLangOf(input) ?? lang
setChatLang(replyLang) setChatLang(replyLang)
const turn = inLang(replyLang, () => respond(view, input)) const turn = inLang(replyLang, () => respond(agent, input))
// The question being answered is kept in the history, with the reply after it. // The question being answered is kept in the history, with the reply after it.
setMessages(ms => [...ms, ...(live.text ? [{ role: 'agent' as const, text: live.text }] : []), { role: 'user', text: input.trim() }]) setMessages(ms => [...ms, ...(live.text ? [{ role: 'agent' as const, text: live.text }] : []), { role: 'user', text: input.trim() }])
setText('') setText('')
@@ -120,8 +110,8 @@ export function InstructionPage() {
// One at a time so a failure (e.g. not an organization owner) is reported per schedule. // One at a time so a failure (e.g. not an organization owner) is reported per schedule.
for (const { id, rule } of items) { for (const { id, rule } of items) {
try { try {
await createSchedule(rule.type === 'content' ? { ...rule, language } : rule, chosen) await createSchedule(rule.type === 'content' ? { ...rule, language } : rule)
done.push({ rule: rule.type === 'content' && chosen.length ? { ...rule, chosen: true } : rule, ok: true }) done.push({ rule, ok: true })
created.add(id) created.add(id)
} catch (e) { } catch (e) {
done.push({ rule, ok: false, message: (e as Error).message }) done.push({ rule, ok: false, message: (e as Error).message })
@@ -142,76 +132,73 @@ export function InstructionPage() {
<> <>
<PageHead title={t('ins.title')} description={t('ins.description')} /> <PageHead title={t('ins.title')} description={t('ins.description')} />
<div className="unified-workspace"> <div className="ai-workspace">
<BrainPanel selected={selected} onSelect={setSelected} /> <div className="card agent-chat">
<div className="ai-column"> <div className="card-head">
<div className="card agent-chat"> <div className="assistant-title">
<div className="card-head"> <span><Icon name="spark" /></span>
<div className="assistant-title"> <div><h3>{t('agent.name')}</h3><small>{phase === 'thinking' ? t('agent.statusThinking') : t('agent.status')}</small></div>
<span><Icon name="spark" /></span>
<div><h3>{t('agent.name')}</h3><small>{phase === 'thinking' ? t('agent.statusThinking') : t('agent.status')}</small></div>
</div>
</div> </div>
<div className="ai-messages" aria-live="polite" ref={chat}>
{messages.map((m, i) => (
<Bubble key={i} message={m} lang={lang} onTyped={i === messages.length - 1 && phase === 'replying' ? replied : undefined} onType={scroll} />
))}
{phase === 'thinking' && <Thinking lang={lang} />}
{phase === 'pausing' && <Typing />}
{idle && live.text && (
<Bubble key={live.text} message={{ role: 'agent', text: live.text, typed: true }} lang={lang} busy={saving}
onTyped={() => setTypedLive(live.text)} onType={scroll} />
)}
</div>
{showOptions && (
<div className="prompt-chips appear">
{live.options.map((o, i) => (
<button key={o} className={i === 0 && !question && agent.items.length ? 'primary' : undefined} onClick={() => send(o)}>{o}</button>
))}
</div>
)}
<form id="ai-request-form" onSubmit={e => { e.preventDefault(); send(text) }}>
<label htmlFor="instruction" className="sr-only">{t('ins.label')}</label>
<textarea id="instruction" rows={2} value={text} placeholder={t('agent.placeholder')} disabled={saving}
onChange={e => setText(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); send(text) } }} />
<div className="ai-compose-footer">
<small>{t('agent.hint')}</small>
<button className="btn primary" type="submit" disabled={!text.trim() || saving || !idle}><Icon name="arrow-right" />{t('agent.send')}</button>
</div>
</form>
</div> </div>
<div className="ai-messages" aria-live="polite" ref={chat}>
{messages.map((m, i) => (
<Bubble key={i} message={m} lang={lang} onTyped={i === messages.length - 1 && phase === 'replying' ? replied : undefined} onType={scroll} />
))}
{phase === 'thinking' && <Thinking lang={lang} />}
{phase === 'pausing' && <Typing />}
{idle && live.text && (
<Bubble key={live.text} message={{ role: 'agent', text: live.text, typed: true }} lang={lang} busy={saving}
onTyped={() => setTypedLive(live.text)} onType={scroll} />
)}
</div>
{showOptions && (
<div className="prompt-chips appear">
{live.options.map((o, i) => (
<button key={o} className={i === 0 && !question && agent.items.length ? 'primary' : undefined} onClick={() => send(o)}>{o}</button>
))}
</div>
)}
<form id="ai-request-form" onSubmit={e => { e.preventDefault(); send(text) }}>
<label htmlFor="instruction" className="sr-only">{t('ins.label')}</label>
<textarea id="instruction" rows={2} value={text} placeholder={t('agent.placeholder')} disabled={saving}
onChange={e => setText(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); send(text) } }} />
<div className="ai-compose-footer">
<small>{t('agent.hint')}</small>
<button className="btn primary" type="submit" disabled={!text.trim() || saving || !idle}><Icon name="arrow-right" />{t('agent.send')}</button>
</div>
</form>
</div>
<div className="card"> <div className="card">
<div className="card-head"><h3>{t('ins.previewTitle')}</h3><span className="badge neutral">{t('ins.readyCount', { n: ready.length })}</span></div> <div className="card-head"><h3>{t('ins.previewTitle')}</h3><span className="badge neutral">{t('ins.readyCount', { n: ready.length })}</span></div>
{agent.items.length === 0 && <div className="empty"><p>{t('agent.planEmpty')}</p></div>} {agent.items.length === 0 && <div className="empty"><p>{t('agent.planEmpty')}</p></div>}
{hasContent && ( {hasContent && (
<label className="plan-language"> <label className="plan-language">
<span>{t('ins.language')}</span> <span>{t('ins.language')}</span>
<select value={agent.language ?? ''} onChange={e => setAgent(s => ({ ...s, language: e.target.value as OutputLanguage }))}> <select value={agent.language ?? ''} onChange={e => setAgent(s => ({ ...s, language: e.target.value as OutputLanguage }))}>
{!agent.language && <option value="" disabled>{t('ins.languagePending')}</option>} {!agent.language && <option value="" disabled>{t('ins.languagePending')}</option>}
{LANGUAGES.map(l => <option key={l} value={l}>{languageLabel(l)}</option>)} {LANGUAGES.map(l => <option key={l} value={l}>{languageLabel(l)}</option>)}
</select> </select>
</label> </label>
)} )}
<div className="rule-list"> <div className="rule-list">
{agent.items.map(({ id, rule }) => ( {agent.items.map(({ id, rule }) => (
<RuleCard key={id} rule={rule} from={sources ?? undefined} onChange={p => setAgent(s => editRule(s, id, p))} onRemove={() => setAgent(s => removeRule(s, id))} /> <RuleCard key={id} rule={rule} onChange={p => setAgent(s => editRule(s, id, p))} onRemove={() => setAgent(s => removeRule(s, id))} />
))} ))}
</div>
{ready.length > 0 && (
<div className="rule-approve">
<small>
<b className="rule-sources"><Icon name="brain" />{chosen.length ? t('ins.sourcesChosen', { n: chosen.length }) : t('ins.sourcesAll')}</b>
{t('ins.approveNote')}
{tooFewSources && <span className="error">{t(chosen.length ? 'ins.tooFewChosen' : 'ins.tooFewInBrain', { n: MIN_PRODUCTION_SOURCES })}</span>}
</small>
<button className="btn primary" disabled={saving || !idle || tooFewSources} onClick={() => approve()}>
<Icon name="check" />{saving ? t('ins.approving') : t('ins.approve', { n: ready.length })}
</button>
</div>
)}
</div> </div>
{ready.length > 0 && (
<div className="rule-approve">
<small>
<b className="rule-sources"><Icon name="brain" />{t('ins.sourcesAll')}</b>
{t('ins.approveNote')}
{tooFewSources && <span className="error">{t('ins.tooFewInBrain', { n: MIN_PRODUCTION_SOURCES })}</span>}
</small>
<button className="btn primary" disabled={saving || !idle || tooFewSources} onClick={() => approve()}>
<Icon name="check" />{saving ? t('ins.approving') : t('ins.approve', { n: ready.length })}
</button>
</div>
)}
</div> </div>
</div> </div>
</> </>

View File

@@ -1,4 +1,4 @@
import { useLayoutEffect, useMemo, useRef, useState } from 'react' import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useUser } from '@clerk/react' import { useUser } from '@clerk/react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
@@ -13,7 +13,33 @@ import { locale, t, type Key } from '../i18n'
import { ContentCard, Preview } from './Results' import { ContentCard, Preview } from './Results'
const HUB_LABEL: Record<Cluster, Key> = { const HUB_LABEL: Record<Cluster, Key> = {
sources: 'prod.hub.sources', made: 'prod.hub.made', working: 'prod.hub.working', research: 'prod.hub.research', schedules: 'prod.hub.schedules', sources: 'prod.hub.sources', made: 'prod.hub.made',
}
// Uses the Fullscreen API on the whole stage (graph + panel); where it is missing (iPhone Safari)
// the stage is pinned over the page instead, and Escape leaves it.
function useFullscreen() {
const ref = useRef<HTMLElement>(null)
const [pinned, setPinned] = useState(false)
const [native, setNative] = useState(false)
useEffect(() => {
const sync = () => setNative(document.fullscreenElement != null && document.fullscreenElement === ref.current)
document.addEventListener('fullscreenchange', sync)
return () => document.removeEventListener('fullscreenchange', sync)
}, [])
useEffect(() => {
if (!pinned) return
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setPinned(false) }
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [pinned])
const toggle = () => {
if (native) void document.exitFullscreen()
else if (pinned) setPinned(false)
else if (ref.current?.requestFullscreen && document.fullscreenEnabled) ref.current.requestFullscreen().catch(() => setPinned(true))
else setPinned(true)
}
return { ref, full: native || pinned, pinned, toggle }
} }
const when = (iso: string) => new Intl.DateTimeFormat(locale(), { weekday: 'short', hour: '2-digit', minute: '2-digit' }).format(new Date(iso)) const when = (iso: string) => new Intl.DateTimeFormat(locale(), { weekday: 'short', hour: '2-digit', minute: '2-digit' }).format(new Date(iso))
@@ -33,16 +59,16 @@ export function ProductionPage() {
}) })
const schedules = useQuery({ queryKey: ['schedules'], queryFn: listSchedules, refetchInterval: 30_000 }) const schedules = useQuery({ queryKey: ['schedules'], queryFn: listSchedules, refetchInterval: 30_000 })
const fullscreen = useFullscreen()
const materials = results.data?.materials ?? [] const materials = results.data?.materials ?? []
const working = materials.filter(isWorking) const working = materials.filter(isWorking)
const made = materials.filter(m => !isWorking(m) && m.status !== 'failed') const made = materials.filter(m => !isWorking(m) && m.status !== 'failed')
// No LMS is connected yet: an approved SCORM module is the package that goes into one. // No LMS is connected yet: an approved SCORM module is the package that goes into one.
const lms = materials.filter(m => m.status === 'accepted' && m.kind === 'scorm') const lms = materials.filter(m => m.status === 'accepted' && m.kind === 'scorm')
const graph = useMemo( const graph = useMemo(
() => (docs.data && results.data && schedules.data () => (docs.data && results.data ? buildGraph({ docs: docs.data, materials: results.data.materials }, c => t(HUB_LABEL[c])) : null),
? buildGraph({ docs: docs.data, materials: results.data.materials, sources: results.data.sources, schedules: schedules.data }, c => t(HUB_LABEL[c])) [docs.data, results.data],
: null),
[docs.data, results.data, schedules.data],
) )
const num = (n: number, ready: boolean, unit: Key) => (ready const num = (n: number, ready: boolean, unit: Key) => (ready
@@ -57,10 +83,15 @@ export function ProductionPage() {
<Link className="btn" to="/upload"><Icon name="upload" />{t('nav.brain')}</Link> <Link className="btn" to="/upload"><Icon name="upload" />{t('nav.brain')}</Link>
<Link className="btn primary" to="/brain"><Icon name="spark" />{t('prod.newContent')}</Link> <Link className="btn primary" to="/brain"><Icon name="spark" />{t('prod.newContent')}</Link>
</>} /> </>} />
<section className="graph-stage" aria-label={t('prod.graphTitle')}> <section ref={fullscreen.ref} className={`graph-stage${fullscreen.full ? ' full' : ''}${fullscreen.pinned ? ' pinned' : ''}`} aria-label={t('prod.graphTitle')}>
<button type="button" className="graph-fullscreen" onClick={fullscreen.toggle} aria-pressed={fullscreen.full}
aria-label={t(fullscreen.full ? 'prod.exitFullscreen' : 'prod.fullscreen')} title={t(fullscreen.full ? 'prod.exitFullscreen' : 'prod.fullscreen')}>
<Icon name={fullscreen.full ? 'shrink' : 'expand'} />
</button>
{graph {graph
? <KnowledgeGraph graph={graph} /> ? <KnowledgeGraph graph={graph} />
: <div className="graph-loading" role="status">{docs.isError || results.isError ? t('brain.engineDown') : t('prod.loadingGraph')}</div>} // The engine's own message: an unreachable engine says so, any other failure says what it was.
: <div className="graph-loading" role="status">{(docs.error ?? results.error)?.message ?? t('prod.loadingGraph')}</div>}
<aside className="graph-panel"> <aside className="graph-panel">
<div className="graph-stats"> <div className="graph-stats">
@@ -264,7 +295,7 @@ function KnowledgeGraph({ graph }: { graph: Graph }) {
</g> </g>
<g> <g>
{graph.nodes.map((n, i) => { {graph.nodes.map((n, i) => {
const r = n.hub ? 9 : n.cluster === 'schedules' ? 5.5 : 3.2 + Math.min(3, (degree.get(n.id) ?? 0) * 0.7) const r = n.hub ? 9 : 3.2 + Math.min(3, (degree.get(n.id) ?? 0) * 0.7)
return ( return (
<g key={n.id} ref={el => { nodeEls.current[i] = el }} <g key={n.id} ref={el => { nodeEls.current[i] = el }}
className={`graph-node c-${n.cluster}${n.hub ? ' hub' : ''}${neighbours && !neighbours.has(n.id) ? ' dim' : ''}${hover === n.id ? ' hover' : ''}${selected?.id === n.id ? ' sel' : ''}`} className={`graph-node c-${n.cluster}${n.hub ? ' hub' : ''}${neighbours && !neighbours.has(n.id) ? ' dim' : ''}${hover === n.id ? ' hover' : ''}${selected?.id === n.id ? ' sel' : ''}`}

View File

@@ -7,8 +7,8 @@ import { toast } from '../components/Toast'
import { describeRule } from '../express/parse' import { describeRule } from '../express/parse'
import { locale, t, type Key } from '../i18n' import { locale, t, type Key } from '../i18n'
import { import {
archiveSchedule, confirmProduction, isRunSchedule, knowledgeReadiness, listRuns, listSchedules, pauseSchedule, PLAN_ACTIVE, productionStatus, quoteProduction, resumeSchedule, archiveSchedule, confirmProduction, isMine, isRunSchedule, knowledgeReadiness, listRuns, listSchedules, scheduleOwners, pauseSchedule, PLAN_ACTIVE, productionStatus, quoteProduction, resumeSchedule,
planProgress, productionError, runNow as startRun, updateTopic, type ContentPlan, type RunSummary, type Schedule, planProgress, productionError, runNow as startRun, updateTopic, type ContentPlan, type Owners, type RunSummary, type Schedule,
} from '../express/schedules' } from '../express/schedules'
const STUDIO_URL: string = import.meta.env.VITE_STUDIO_URL const STUDIO_URL: string = import.meta.env.VITE_STUDIO_URL
@@ -33,25 +33,61 @@ const runError = (code: string) => (RUN_ERRORS.has(code) ? t(`runErr.${code}` as
const issueReason = (reason: string) => (ISSUE_REASONS.has(reason) ? t(`issue.${reason}` as Key) : reason) const issueReason = (reason: string) => (ISSUE_REASONS.has(reason) ? t(`issue.${reason}` as Key) : reason)
const isNotReady = (r: RunSummary) => r.safe_error_code === 'knowledge_not_ready' || r.has_readiness_issues const isNotReady = (r: RunSummary) => r.safe_error_code === 'knowledge_not_ready' || r.has_readiness_issues
// Which schedules the page starts with; remembered per device.
type Scope = 'mine' | 'all'
const SCOPE_KEY = 'express.scheduleScope'
const savedScope = (): Scope => {
try { return localStorage.getItem(SCOPE_KEY) === 'all' ? 'all' : 'mine' } catch { return 'mine' }
}
export function SchedulePage() { export function SchedulePage() {
const schedules = useQuery({ queryKey: ['schedules'], queryFn: listSchedules, refetchInterval: 30_000 }) const schedules = useQuery({ queryKey: ['schedules'], queryFn: listSchedules, refetchInterval: 30_000 })
const owners = useQuery({ queryKey: ['scheduleOwners'], queryFn: scheduleOwners, staleTime: 5 * 60_000 })
const [scope, setScope] = useState<Scope>(savedScope)
const choose = (next: Scope) => {
setScope(next)
try { localStorage.setItem(SCOPE_KEY, next) } catch { /* keep it for this visit only */ }
}
const all = schedules.data
const mine = all && owners.data ? all.filter(s => isMine(s, owners.data)) : undefined
// Members only ever see their own, so the switch appears only when someone else's schedules are listed.
const mixed = !!all && !!mine && mine.length < all.length
const shown = mixed && scope === 'mine' ? mine : all
return ( return (
<> <>
<PageHead title={t('sch.title')} description={t('sch.description')} <PageHead title={t('sch.title')} description={t('sch.description')}
actions={<Link className="btn primary" to="/brain"><Icon name="plus" />{t('sch.newInstruction')}</Link>} /> actions={<Link className="btn primary" to="/brain"><Icon name="plus" />{t('sch.newInstruction')}</Link>} />
{mixed && (
<div className="result-tabs" role="tablist">
<button role="tab" aria-selected={scope === 'mine'} className={scope === 'mine' ? 'active' : undefined} onClick={() => choose('mine')}>
{t('sch.mine')} <b>{mine.length}</b>
</button>
<button role="tab" aria-selected={scope === 'all'} className={scope === 'all' ? 'active' : undefined} onClick={() => choose('all')}>
{t('sch.all')} <b>{all.length}</b>
</button>
</div>
)}
<div className="card"> <div className="card">
<div className="card-head"><h3>{t('sch.cardTitle')}</h3><Icon name="calendar" /></div> <div className="card-head"><h3>{t('sch.cardTitle')}</h3><Icon name="calendar" /></div>
{schedules.isPending && <SkeletonRows label={t('sch.loading')} />} {schedules.isPending && <SkeletonRows label={t('sch.loading')} />}
{schedules.isError && <Empty icon="info" title={t('sch.loadFailed')}>{schedules.error.message}</Empty>} {schedules.isError && <Empty icon="info" title={t('sch.loadFailed')}>{schedules.error.message}</Empty>}
{schedules.data?.length === 0 && <Empty icon="calendar" title={t('sch.empty')}>{t('sch.emptyCopy')}</Empty>} {all?.length === 0 && <Empty icon="calendar" title={t('sch.empty')}>{t('sch.emptyCopy')}</Empty>}
{!!schedules.data?.length && <div className="result-list">{schedules.data.map(s => <ScheduleRow key={s.agent.id} schedule={s} />)}</div>} {mixed && shown?.length === 0 && <Empty icon="calendar" title={t('sch.emptyMine')}>{t('sch.emptyMineCopy')}</Empty>}
{!!shown?.length && <div className="result-list">{shown.map(s => <ScheduleRow key={s.agent.id} schedule={s} owners={owners.data} />)}</div>}
</div> </div>
</> </>
) )
} }
function ScheduleRow({ schedule }: { schedule: Schedule }) { function createdBy(schedule: Schedule, owners: Owners) {
if (isMine(schedule, owners)) return t('sch.byYou')
const name = schedule.agent.created_by_user_id != null ? owners.names.get(schedule.agent.created_by_user_id) : undefined
return name ? t('sch.by', { name }) : t('sch.byUnknown')
}
function ScheduleRow({ schedule, owners }: { schedule: Schedule; owners?: Owners }) {
const { agent, rule, active } = schedule const { agent, rule, active } = schedule
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [editing, setEditing] = useState(false) const [editing, setEditing] = useState(false)
@@ -84,6 +120,7 @@ function ScheduleRow({ schedule }: { schedule: Schedule }) {
<span className="rule-icon"><Icon name={ruleIcon(rule)} /></span> <span className="rule-icon"><Icon name={ruleIcon(rule)} /></span>
<div style={{ minWidth: 0, flex: 1 }}> <div style={{ minWidth: 0, flex: 1 }}>
<b>{describeRule(rule)}</b> <b>{describeRule(rule)}</b>
{owners && <div className="schedule-owner"><small><Icon name="user" />{createdBy(schedule, owners)}</small></div>}
<div><small>{production <div><small>{production
? <ProductionLine agentId={agent.id} active={active} /> ? <ProductionLine agentId={agent.id} active={active} />
: t(video ? 'sch.nextVideo' : 'sch.nextResearch', { when: active ? fmt(agent.next_run_at) : t('sch.paused') })}</small></div> : t(video ? 'sch.nextVideo' : 'sch.nextResearch', { when: active ? fmt(agent.next_run_at) : t('sch.paused') })}</small></div>
@@ -233,6 +270,7 @@ function RunHistory({ agentId }: { agentId: number }) {
<span className={`status ${r.status === 'completed' ? 'ready' : 'draft'}`}><i className="dot"></i>{runStatus(r.status)}</span> <span className={`status ${r.status === 'completed' ? 'ready' : 'draft'}`}><i className="dot"></i>{runStatus(r.status)}</span>
{r.budget_spent != null && <span>{t('common.credits', { n: r.budget_spent })}</span>} {r.budget_spent != null && <span>{t('common.credits', { n: r.budget_spent })}</span>}
{r.safe_error_code && <span className="error" title={r.safe_error_code}>{runError(r.safe_error_code)}</span>} {r.safe_error_code && <span className="error" title={r.safe_error_code}>{runError(r.safe_error_code)}</span>}
{r.source_outcome === 'no_new_sources' && <span>{t('sch.noNewSources')}</span>}
{r.id === blockedId && <ReadinessIssues agentId={agentId} />} {r.id === blockedId && <ReadinessIssues agentId={agentId} />}
</li> </li>
))} ))}

View File

@@ -40,6 +40,8 @@ textarea{resize:vertical;line-height:1.6}
.rule-outcomes .error{margin:0;font-size:11px} .rule-outcomes .error{margin:0;font-size:11px}
.schedule-row{border-bottom:1px solid var(--line);padding-bottom:14px} .schedule-row{border-bottom:1px solid var(--line);padding-bottom:14px}
.schedule-row:last-child{border-bottom:0} .schedule-row:last-child{border-bottom:0}
.schedule-owner small{display:inline-flex;align-items:center;gap:5px;color:var(--muted)}
.schedule-owner svg{width:12px;height:12px}
.schedule-row .result-row{border-bottom:0;padding-bottom:10px} .schedule-row .result-row{border-bottom:0;padding-bottom:10px}
.schedule-row .error{display:block;margin-top:6px;font-size:11px} .schedule-row .error{display:block;margin-top:6px;font-size:11px}
.schedule-actions{display:flex;gap:7px;flex-wrap:wrap;padding-left:61px} .schedule-actions{display:flex;gap:7px;flex-wrap:wrap;padding-left:61px}

View File

@@ -77,8 +77,11 @@ button.workspace:hover,button.workspace[aria-expanded=true]{border-color:var(--s
.sidebar-help p{font-size:12px} .sidebar-help p{font-size:12px}
.sidebar-help button{color:var(--blue-dark);font-size:12.5px} .sidebar-help button{color:var(--blue-dark);font-size:12.5px}
.profile{border-top:1px solid var(--line);padding:14px 4px 0} .profile{border-top:1px solid var(--line);padding:14px 4px 0}
.profile>div{flex:1;min-width:0}
.profile b,.profile small{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.profile b{font-size:13px} .profile b{font-size:13px}
.profile small{font-size:11.5px} .profile small{font-size:11.5px}
.profile>.icon-btn{flex-shrink:0}
.avatar{background:linear-gradient(140deg,#2e90fa,#1570ef);color:#fff;width:34px;height:34px;font-weight:700;box-shadow:0 0 0 2px #fff,0 0 0 3.5px rgba(46,144,250,.5);flex-shrink:0} .avatar{background:linear-gradient(140deg,#2e90fa,#1570ef);color:#fff;width:34px;height:34px;font-weight:700;box-shadow:0 0 0 2px #fff,0 0 0 3.5px rgba(46,144,250,.5);flex-shrink:0}
/* Main column: glass top bar and a soft blue glow at the top */ /* Main column: glass top bar and a soft blue glow at the top */
@@ -304,8 +307,8 @@ dialog::backdrop{background:rgba(16,24,40,.45);backdrop-filter:blur(4px)}
.live-dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:#4ade80;box-shadow:0 0 0 0 rgba(74,222,128,.6);animation:live-pulse 1.8s ease-out infinite;flex-shrink:0} .live-dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:#4ade80;box-shadow:0 0 0 0 rgba(74,222,128,.6);animation:live-pulse 1.8s ease-out infinite;flex-shrink:0}
@keyframes live-pulse{70%{box-shadow:0 0 0 8px rgba(74,222,128,0)}100%{box-shadow:0 0 0 0 rgba(74,222,128,0)}} @keyframes live-pulse{70%{box-shadow:0 0 0 8px rgba(74,222,128,0)}100%{box-shadow:0 0 0 0 rgba(74,222,128,0)}}
.graph-stage{--g-sources:#6ce9a6;--g-made:#fde272;--g-working:#c3b5fd;--g-research:#84caff;--g-schedules:#fdb022; .graph-stage{--g-sources:#6ce9a6;--g-made:#fde272;--g-working:#c3b5fd;
position:relative;min-height:640px;border-radius:22px;overflow:hidden;color:#e4e7ec; position:relative;height:640px;max-height:calc(100vh - 260px);max-height:calc(100dvh - 260px);min-height:440px;border-radius:22px;overflow:hidden;color:#e4e7ec;
background:radial-gradient(ellipse at 45% 50%,#0f2a4d 0%,#0a1628 55%,#060d1a 100%);box-shadow:0 24px 60px -28px rgba(16,24,40,.6)} background:radial-gradient(ellipse at 45% 50%,#0f2a4d 0%,#0a1628 55%,#060d1a 100%);box-shadow:0 24px 60px -28px rgba(16,24,40,.6)}
.graph-svg{position:absolute;inset:0;width:calc(100% - 300px);height:100%;display:block} .graph-svg{position:absolute;inset:0;width:calc(100% - 300px);height:100%;display:block}
.graph-loading{position:absolute;inset:0 300px 0 0;display:grid;place-items:center;color:#98a2b3;font-size:13px} .graph-loading{position:absolute;inset:0 300px 0 0;display:grid;place-items:center;color:#98a2b3;font-size:13px}
@@ -341,9 +344,7 @@ dialog::backdrop{background:rgba(16,24,40,.45);backdrop-filter:blur(4px)}
.graph-node text{fill:#fff;font-size:13px;font-weight:700;letter-spacing:.01em;text-anchor:middle;paint-order:stroke;stroke:#08142a;stroke-width:4px;stroke-linejoin:round;pointer-events:none} .graph-node text{fill:#fff;font-size:13px;font-weight:700;letter-spacing:.01em;text-anchor:middle;paint-order:stroke;stroke:#08142a;stroke-width:4px;stroke-linejoin:round;pointer-events:none}
.graph-node:not(.hub) text{font-size:11.5px;font-weight:600;opacity:0;transform:translateY(4px);transition:opacity .2s,transform .2s} .graph-node:not(.hub) text{font-size:11.5px;font-weight:600;opacity:0;transform:translateY(4px);transition:opacity .2s,transform .2s}
.graph-node:not(.hub):is(.hover,.sel) text{opacity:1;transform:none} .graph-node:not(.hub):is(.hover,.sel) text{opacity:1;transform:none}
.graph-node.c-working:not(.hub) .core{animation:node-pulse 1.6s ease-in-out infinite} .c-sources{--c:var(--g-sources)}.c-made{--c:var(--g-made)}
@keyframes node-pulse{50%{opacity:.4}}
.c-sources{--c:var(--g-sources)}.c-made{--c:var(--g-made)}.c-working{--c:var(--g-working)}.c-research{--c:var(--g-research)}.c-schedules{--c:var(--g-schedules)}
.graph-note{position:absolute;top:16px;left:16px;z-index:2;display:flex;align-items:center;gap:8px;max-width:min(460px,calc(100% - 340px)); .graph-note{position:absolute;top:16px;left:16px;z-index:2;display:flex;align-items:center;gap:8px;max-width:min(460px,calc(100% - 340px));
padding:8px 14px;border-radius:999px;background:rgba(10,22,40,.8);border:1px solid rgba(255,255,255,.12);font-size:12.5px;backdrop-filter:blur(8px)} padding:8px 14px;border-radius:999px;background:rgba(10,22,40,.8);border:1px solid rgba(255,255,255,.12);font-size:12.5px;backdrop-filter:blur(8px)}
@@ -354,7 +355,7 @@ dialog::backdrop{background:rgba(16,24,40,.45);backdrop-filter:blur(4px)}
.graph-legend{position:absolute;left:16px;bottom:16px;z-index:2;display:flex;flex-wrap:wrap;gap:6px 14px;list-style:none;margin:0;padding:0;font-size:11.5px;color:#98a2b3} .graph-legend{position:absolute;left:16px;bottom:16px;z-index:2;display:flex;flex-wrap:wrap;gap:6px 14px;list-style:none;margin:0;padding:0;font-size:11.5px;color:#98a2b3}
.graph-legend li{display:flex;align-items:center;gap:6px} .graph-legend li{display:flex;align-items:center;gap:6px}
.node-dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--c);box-shadow:0 0 6px var(--c);flex-shrink:0} .node-dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--c);box-shadow:0 0 6px var(--c);flex-shrink:0}
.node-dot.sources{--c:var(--g-sources)}.node-dot.made{--c:var(--g-made)}.node-dot.working{--c:var(--g-working)}.node-dot.research{--c:var(--g-research)}.node-dot.schedules{--c:var(--g-schedules)} .node-dot.sources{--c:var(--g-sources)}.node-dot.made{--c:var(--g-made)}.node-dot.working{--c:var(--g-working)}
.graph-panel{position:absolute;top:16px;right:16px;bottom:16px;width:284px;z-index:2;overflow:auto;padding:14px;border-radius:18px; .graph-panel{position:absolute;top:16px;right:16px;bottom:16px;width:284px;z-index:2;overflow:auto;padding:14px;border-radius:18px;
background:rgba(10,22,40,.72);border:1px solid rgba(255,255,255,.1);backdrop-filter:blur(10px)} background:rgba(10,22,40,.72);border:1px solid rgba(255,255,255,.1);backdrop-filter:blur(10px)}
@@ -377,8 +378,15 @@ dialog::backdrop{background:rgba(16,24,40,.45);backdrop-filter:blur(4px)}
.graph-empty a,.graph-more{color:#84caff} .graph-empty a,.graph-more{color:#84caff}
.graph-more{display:block;font-size:12px;margin:8px 4px 0} .graph-more{display:block;font-size:12px;margin:8px 4px 0}
.graph-fullscreen{position:absolute;top:16px;right:316px;z-index:3;display:grid;place-items:center;width:34px;height:34px;padding:0;border:1px solid rgba(255,255,255,.14);border-radius:10px;background:rgba(10,22,40,.6);backdrop-filter:blur(8px);color:#d0d5dd;cursor:pointer;transition:background .15s,color .15s}
.graph-fullscreen:hover{background:rgba(46,144,250,.25);color:#fff}
.graph-fullscreen svg{width:16px;height:16px}
.graph-stage.full{border-radius:0;min-height:0;max-height:none;height:100vh;height:100dvh;box-shadow:none}
.graph-stage.pinned{position:fixed;inset:0;z-index:50}
@media(max-width:900px){ @media(max-width:900px){
.graph-stage{min-height:0;display:flex;flex-direction:column} .graph-stage{height:auto;max-height:none;min-height:0;display:flex;flex-direction:column}
.graph-stage.full{overflow:auto}
.graph-fullscreen{right:16px}
.graph-svg{position:relative;width:100%;height:auto;aspect-ratio:1000/640} .graph-svg{position:relative;width:100%;height:auto;aspect-ratio:1000/640}
.graph-loading{position:relative;inset:auto;min-height:280px} .graph-loading{position:relative;inset:auto;min-height:280px}
.graph-note{position:relative;top:auto;left:auto;margin:16px 16px 0;max-width:none;align-self:flex-start} .graph-note{position:relative;top:auto;left:auto;margin:16px 16px 0;max-width:none;align-self:flex-start}
@@ -395,3 +403,26 @@ dialog::backdrop{background:rgba(16,24,40,.45);backdrop-filter:blur(4px)}
.activity-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:18px} .activity-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:18px}
.activity-empty{padding:28px 0;text-align:center} .activity-empty{padding:28px 0;text-align:center}
@media(max-width:1000px){.activity-grid{grid-template-columns:1fr}} @media(max-width:1000px){.activity-grid{grid-template-columns:1fr}}
/* Collapsible sidebar: the toggle in the topbar shrinks it to an icon rail (desktop only) */
.sidebar,.workspace-main{transition:width .2s,margin-left .2s}
.sidebar-toggle{flex-shrink:0;margin-right:12px}
.topbar{justify-content:flex-start}.topbar-right{margin-left:auto}
.sidebar-help-icon{align-self:center;margin:auto 0 12px}
@media(min-width:701px){
.sidebar-collapsed .sidebar{width:76px;padding-left:12px;padding-right:12px}
.sidebar-collapsed .workspace-main{margin-left:76px;width:calc(100% - 76px)}
.sidebar-collapsed .sidebar>a>.brand{margin:0 0 24px;justify-content:center}
.sidebar-collapsed .sidebar .brand>span:last-child,
.sidebar-collapsed .workspace>div,.sidebar-collapsed .workspace>svg,
.sidebar-collapsed .sidebar nav button.nav-label,.sidebar-collapsed .nav-text,
.sidebar-collapsed .profile>div{display:none}
.sidebar-collapsed button.workspace{justify-content:center;padding:8px}
.sidebar-collapsed .workspace-menu{right:auto;width:240px}
.sidebar-collapsed .sidebar nav :is(button,a){justify-content:center;padding:11px 0}
.sidebar-collapsed .sidebar nav .nav-items~.nav-items>:first-child{margin-top:12px}
.sidebar-collapsed .profile{flex-direction:column;padding-left:0;padding-right:0}
.sidebar-collapsed .profile>.icon-btn{margin-left:0}
}
@media(max-width:700px){.sidebar-toggle,.sidebar-help-icon{display:none}}
@media(prefers-reduced-motion:reduce){.sidebar,.workspace-main{transition:none}}