Compare commits

3 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
8 changed files with 103 additions and 11 deletions

View File

@@ -187,7 +187,7 @@ export function AppLayout() {
</div>} </div>}
<div className="profile"> <div className="profile">
<span className="avatar" title={collapsed ? name : undefined}>{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>

View File

@@ -23,6 +23,7 @@ const paths: Record<string, string> = {
sidebar: '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M9 4v16"/>', 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,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'

View File

@@ -206,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',
@@ -697,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',

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>

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 */
@@ -305,7 +308,7 @@ dialog::backdrop{background:rgba(16,24,40,.45);backdrop-filter:blur(4px)}
@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; .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}
@@ -378,10 +381,10 @@ dialog::backdrop{background:rgba(16,24,40,.45);backdrop-filter:blur(4px)}
.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{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:hover{background:rgba(46,144,250,.25);color:#fff}
.graph-fullscreen svg{width:16px;height:16px} .graph-fullscreen svg{width:16px;height:16px}
.graph-stage.full{border-radius:0;min-height:0;height:100vh;height:100dvh;box-shadow:none} .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} .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-stage.full{overflow:auto}
.graph-fullscreen{right:16px} .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}