diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 001182f421..6f109ca6da 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -80,6 +80,7 @@ even when the underlying access was authorized. - After: - States and viewports shown: - Source data: +- Attention review: ## Type of Change diff --git a/AGENTS.md b/AGENTS.md index b37662db43..4910de668b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -232,6 +232,9 @@ gates. ## UI Design Standard +Apply the whole-viewport attention review in `docs/development/design.md` +("Earn The User's Attention") before implementation and in PR visual evidence. + Before changing or reproducing any LoopX UI, read and follow the repository-root `docs/development/design.md`. This includes websites, dashboards, desktop applications, documentation, prototypes, screenshots, and framework migrations. When an diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index 10a26c7f27..309a8f6a0d 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -864,6 +864,27 @@ export type LoopXModeSnapshot = { export function fetchLoopXMode(sessionId: string) { return requestJson(`/api/chat/sessions/${sessionId}/loopx`); } +export type DelegationInventory = { + items: Array<{record_id: string; operation_id: string | null; agent_id?: string; todo_id?: string; + status: string; worker_active?: boolean; recovery_required: boolean | null; + artifacts?: Array<{ref: string; sha256: string}>}>; + has_more: boolean; next_cursor: string | null; page_readback_complete: boolean; +}; +export type DelegationPreflight = { + state: "turn_blocked" | "acceptance_unavailable" | "runtime_unavailable" | "runtime_unverified" | "launchable"; + turn_eligible: boolean; acceptance_ready: boolean; turn_route: string; + executor: {host: string; available: boolean | null; reason: string | null; profile: string | null}; +}; +export function fetchLoopXTeamWork(sessionId: string, cursor?: string) { + return requestJson(`/api/chat/sessions/${sessionId}/loopx`, { + method: "POST", body: JSON.stringify({operation: "operations", limit: 10, ...(cursor ? {cursor} : {})}), + }); +} +export function inspectLoopXMember(sessionId: string, bindingId: string) { + return requestJson(`/api/chat/sessions/${sessionId}/loopx`, { + method: "POST", body: JSON.stringify({operation: "inspect", binding_id: bindingId}), + }); +} export function updateLoopXMode(sessionId: string, operation: string, settings?: LoopXModeSettings, operationId = crypto.randomUUID()) { return requestJson(`/api/chat/sessions/${sessionId}/loopx`, { method: "POST", body: JSON.stringify({operation, operation_id: operationId, ...(settings ? {settings} : {})}), diff --git a/apps/presentation/dashboard/src/features/personal-workspace/cards/run-row.tsx b/apps/presentation/dashboard/src/features/personal-workspace/cards/run-row.tsx index 1a399b0cf5..6c1a4b591c 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/cards/run-row.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/cards/run-row.tsx @@ -12,22 +12,16 @@ const runStatusKey: Record = { waiting: "runs.waiting", }; -export function RunRow({ onSelect, run }: { onSelect: () => void; run: WorkspaceRun }) { +export function RunRow({ onSelect, run, showGoal = true }: { onSelect: () => void; run: WorkspaceRun; showGoal?: boolean }) { const { t } = useWorkspaceI18n(); - const progress = run.totalSteps > 0 ? Math.min(100, (run.completedSteps / run.totalSteps) * 100) : 0; return ( ); diff --git a/apps/presentation/dashboard/src/features/personal-workspace/channel-header.tsx b/apps/presentation/dashboard/src/features/personal-workspace/channel-header.tsx index 5f877de98f..06d74dd06f 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/channel-header.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/channel-header.tsx @@ -3,7 +3,6 @@ import { Bot, Eye, Menu, RefreshCw, SlidersHorizontal } from "lucide-react"; import { localizedGoalState, useWorkspaceI18n } from "./i18n"; import type { ManagerChannelBinding, ManagerRuntimeSessionReadback } from "../../data/chat"; import type { WorkspaceAgentOption, WorkspaceGoal, WorkspaceGoalTab } from "./personal-workspace-model"; -import { goalUsageLabel } from "./personal-workspace-model"; import { WorkspaceSelect } from "./workspace-select"; export function ChannelHeader({ @@ -44,15 +43,6 @@ export function ChannelHeader({ selectedGoalTab: WorkspaceGoalTab; }) { const { locale, t } = useWorkspaceI18n(); - const selectedGoalUsageLabel = selectedGoal - ? goalUsageLabel(selectedGoal.usage, { - cost: t("drawer.costShort"), - duration: t("drawer.durationShort"), - period24h: t("drawer.period24h"), - period7d: t("drawer.period7d"), - tokens: t("drawer.tokensShort"), - }) - : null; // The chip reports the selected executor, whose credential pays for it, and // the resolved model, so an executor and a model that disagree are visible // instead of arriving as one silent configuration. @@ -121,17 +111,7 @@ export function ChannelHeader({

{selectedGoal?.title ?? t("header.manager")}

- {!selectedGoal && managerRuntime ? ( -

{managerRuntime.status === "ready" - ? t("header.managerRuntime", { - profile: managerRuntime.runtime_profile, - sandbox: managerRuntime.sandbox, - }) - : t("header.managerRuntimeFallback", { - profile: managerRuntime.runtime_profile, - sandbox: managerRuntime.sandbox, - })}

- ) : null} + {selectedGoal && !selectedGoal.loadState && !["安静运行", "推进中"].includes(selectedGoal.state) ?

{localizedGoalState(selectedGoal.state, locale)}

: null} {!selectedGoal && managerChannelBinding ? (

@@ -148,16 +128,27 @@ export function ChannelHeader({ })} ) : null} - {managerExecutionDefaultReason ? ( +

+ ) : null} + {!selectedGoal && (managerRuntime || managerExecutionDefaultReason) ?
{locale === "zh-CN" ? "运行环境" : "Execution environment"} + {!selectedGoal && managerRuntime ? ( +

{managerRuntime.status === "ready" + ? t("header.managerRuntime", { + profile: managerRuntime.runtime_profile, + sandbox: managerRuntime.sandbox, + }) + : t("header.managerRuntimeFallback", { + profile: managerRuntime.runtime_profile, + sandbox: managerRuntime.sandbox, + })}

+ ) : null} + {managerExecutionDefaultReason && managerChannelBinding ? ( {t(managerExecutionDefaultReason, { executor: managerChannelBinding.executor_endpoint })} ) : null} -

- ) : null} - {selectedGoal ?

{selectedGoal.loadState ? t(selectedGoal.loadState === "error" ? "startup.goalError" : "startup.goalLoading") : `${selectedGoal.agentLaneCount && selectedGoal.agentLaneCount > 1 - ? t("header.workAgentCount", { count: selectedGoal.agentLaneCount }) - : selectedGoal.agentLabel ?? selectedGoal.agentId} · ${(selectedGoal.loadState ? t(selectedGoal.loadState === "error" ? "startup.goalError" : "startup.goalLoading") : localizedGoalState(selectedGoal.state, locale))}${selectedGoalUsageLabel ? ` · ${selectedGoalUsageLabel}` : ""} · ${selectedGoal.nextSentence}`}

: null} +
: null} + {selectedGoal?.loadState ?

{t(selectedGoal.loadState === "error" ? "startup.goalError" : "startup.goalLoading")}

: null}
{selectedGoal ? (
@@ -180,7 +171,6 @@ export function ChannelHeader({ ) : null} {!selectedGoal ? runtimeControl : null} - {t("header.live")} {onRefresh ? ( {refreshState === "loading" ? {t("header.refreshing")} : refreshState === "done" ? {t("header.refreshDone")} : refreshState === "error" ? {t("header.refreshFailed")} : null} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/channel-timeline.tsx b/apps/presentation/dashboard/src/features/personal-workspace/channel-timeline.tsx index c9bffe2a6e..111385b894 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/channel-timeline.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/channel-timeline.tsx @@ -1,5 +1,5 @@ import { CollaborationCard } from "./collaboration-card"; -import { Bot, Sparkles } from "lucide-react"; +import { Activity, Bot, Sparkles } from "lucide-react"; import { AttentionRow } from "./cards/attention-row"; import { MarkdownText } from "./markdown"; @@ -19,7 +19,7 @@ export function ChannelTimeline({ onSelect: (selection: WorkspaceDrawerSelection) => void; selectedGoal: WorkspaceGoal | null; }) { - const { t } = useWorkspaceI18n(); + const { locale, t } = useWorkspaceI18n(); if (items.length === 0) { return (
@@ -44,7 +44,19 @@ export function ChannelTimeline({ const gatedItems = items.filter((item): item is Extract => item.kind === "proposal" && item.proposal.status === "gated"); - const primaryItems = items.filter((item) => item.kind !== "proposal"); + // Only routine execution is folded. Waiting, interruption, and failures stay + // visible; no prose-based inference that a waiting run is safe to ignore. + const routineRuns = items.filter((item): item is Extract => + item.kind === "run" && ["queued", "running", "completed"].includes(item.run.status)); + const routineIds = new Set(routineRuns.map(item => item.id)); + const primaryItems = items.filter(item => item.kind !== "proposal" && !routineIds.has(item.id)); + const workingCount = routineRuns.filter(item => item.run.status === "running" && Boolean(item.run.sessionId) && Boolean(item.run.canInterrupt)).length; + const queuedCount = routineRuns.filter(item => item.run.status === "queued").length; + const completedCount = routineRuns.filter(item => item.run.status === "completed").length; + const progressCount = routineRuns.length - workingCount - queuedCount - completedCount; + const activitySummary = locale === "zh-CN" + ? [workingCount && `${workingCount} 个执行中`, queuedCount && `${queuedCount} 个排队中`, completedCount && `${completedCount} 次执行已结束`, progressCount && `${progressCount} 项进展更新`].filter(Boolean).join(" · ") + : [workingCount && `${workingCount} running`, queuedCount && `${queuedCount} queued`, completedCount && `${completedCount} runs finished`, progressCount && `${progressCount} progress updates`].filter(Boolean).join(" · "); const activeProposalItems = items.filter((item): item is Extract => item.kind === "proposal" && item.proposal.status !== "gated"); @@ -53,7 +65,7 @@ export function ChannelTimeline({ return onSelect({ item: item.attention, kind: "attention" })} />; } if (item.kind === "run") { - return onSelect({ item: item.run, kind: "run" })} run={item.run} />; + return onSelect({ item: item.run, kind: "run" })} run={item.run} />; } if (item.kind === "output") { return onSelect({ item: item.output, kind: "output" })} output={item.output} />; @@ -89,6 +101,10 @@ export function ChannelTimeline({ <>

{liveAnnouncement}

+ {routineRuns.length ?
+ +
{routineRuns.map(renderItem)}
+
: null} {primaryItems.map(renderItem)} {gatedItems.length ? (
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-loopx-mode.css b/apps/presentation/dashboard/src/features/personal-workspace/goal-loopx-mode.css index 910bc6a3e9..31769aa96d 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-loopx-mode.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-loopx-mode.css @@ -1 +1,41 @@ -.goal-loopx-mode{color:var(--pw-text,#171717);font-size:13px;margin-bottom:10px}.goal-loopx-mode-bar{display:flex;align-items:center;justify-content:space-between;gap:16px}.goal-loopx-mode strong{font-weight:500}.goal-loopx-mode p{font-size:12px;color:var(--pw-muted,#666);margin:4px 0}.goal-loopx-mode-actions{display:flex;gap:8px;flex-shrink:0}.goal-loopx-mode button{display:inline-flex;align-items:center;justify-content:center;gap:6px;min-height:40px;border:1px solid var(--pw-line,#ebebeb);background:var(--pw-surface,#fff);color:inherit;border-radius:6px;padding:8px 12px;font:inherit;cursor:pointer}.goal-loopx-mode button:disabled{opacity:.5;cursor:default}.goal-loopx-mode button:focus-visible,.goal-loopx-mode input:focus-visible,.goal-loopx-mode select:focus-visible{outline:2px solid #0070f3;outline-offset:2px}.goal-loopx-mode-settings{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:12px;padding:16px;border:1px solid var(--pw-line,#ebebeb);border-radius:12px;background:var(--pw-surface,#fff)}.goal-loopx-mode-settings label{display:flex;flex-direction:column;gap:6px}.goal-loopx-mode-settings input,.goal-loopx-mode-settings select{min-width:0;width:100%;padding:8px;border:1px solid var(--pw-line,#ddd);border-radius:6px;background:var(--pw-surface,#fff);color:inherit;font:inherit}.goal-loopx-mode-settings label:nth-child(3),.goal-loopx-mode-settings p{grid-column:1/-1}.goal-loopx-mode-members{display:flex;flex-wrap:wrap;gap:8px;margin-top:8px}.goal-loopx-mode-members span{padding:4px 8px;border:1px solid var(--pw-line,#ebebeb);border-radius:6px;font-size:12px}.goal-loopx-message-mode{display:flex;align-items:center;gap:8px;margin:8px 0;font-size:12px}.goal-loopx-message-mode select{font:inherit;color:inherit;border:1px solid var(--pw-line,#ebebeb);border-radius:6px;background:var(--pw-surface,#fff);padding:6px}@media(max-width:640px){.goal-loopx-mode-bar{align-items:flex-start;flex-wrap:wrap;gap:8px}.goal-loopx-mode-actions{flex-wrap:wrap}.goal-loopx-mode-settings{grid-template-columns:1fr}.goal-loopx-mode button{min-height:44px}} +.goal-loopx-mode { color: var(--pw-text, #171717); font-size: 13px; padding: 8px 26px; border-bottom: 1px solid var(--pw-line, #ebebeb); } +.goal-loopx-mode-bar, .goal-loopx-mode-actions { display: flex; align-items: center; gap: 8px; } +.goal-loopx-mode-bar { justify-content: space-between; gap: 16px; } +.goal-loopx-mode-status { display: inline-flex; align-items: center; gap: 8px; min-width: 0; font-weight: 500; } +.goal-loopx-mode-status > i { width: 7px; height: 7px; flex: 0 0 7px; border-radius: 50%; background: var(--pw-muted, #666); } +.goal-loopx-mode-status[data-active="true"] > i { background: var(--pw-green, #13804a); } +.goal-loopx-mode p { font-size: 12px; color: var(--pw-muted, #666); margin: 8px 0; line-height: 1.6; } +.goal-loopx-mode .personal-composer-error { color: var(--pw-red, #b42318); } +.goal-loopx-mode button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-width: 44px; min-height: 44px; border: 1px solid var(--pw-line, #ebebeb); background: var(--pw-surface, #fff); color: inherit; border-radius: 6px; padding: 8px 12px; font: inherit; cursor: pointer; } +.goal-loopx-mode .goal-loopx-primary { font-weight: 500; border-color: var(--pw-line-strong, #ddd); } +.goal-loopx-mode .goal-loopx-team-trigger { border-color: transparent; background: transparent; } +.goal-loopx-mode button:disabled { opacity: .5; cursor: default; } +.goal-loopx-mode button:focus-visible, .goal-loopx-mode input:focus-visible, .goal-loopx-mode select:focus-visible, .goal-loopx-mode summary:focus-visible { outline: 2px solid #0070f3; outline-offset: 2px; } +.goal-loopx-alert-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--pw-amber, #a66b00); } +.goal-loopx-mode .goal-loopx-review-notice { color: var(--pw-amber, #a66b00); border: 0; padding-inline: 0; background: transparent; text-align: left; } +.goal-loopx-dialog { margin: auto; padding: 0; width: min(680px, calc(100vw - 32px)); max-height: calc(100dvh - 48px); border: 1px solid var(--pw-line, #ebebeb); border-radius: 16px; background: var(--pw-bg, #fafafa); color: var(--pw-text, #171717); overflow: auto; overscroll-behavior: contain; } +.goal-loopx-dialog::backdrop { background: rgb(0 0 0 / 28%); } +.goal-loopx-dialog-content { padding: 24px; } +.goal-loopx-dialog header { display: flex; align-items: center; justify-content: space-between; gap: 16px; } +.goal-loopx-dialog h2 { margin: 0; font-size: 20px; font-weight: 600; letter-spacing: -.02em; } +.goal-loopx-dialog h3 { margin: 24px 0 8px; font-size: 13px; font-weight: 600; } +.goal-loopx-mode-settings { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-block: 24px; } +.goal-loopx-mode-settings label { display: flex; flex-direction: column; gap: 8px; } +.goal-loopx-mode-settings input, .goal-loopx-mode-settings select { min-width: 0; width: 100%; min-height: 44px; padding: 8px; border: 1px solid var(--pw-line, #ddd); border-radius: 6px; background: var(--pw-surface, #fff); color: inherit; font: inherit; } +.goal-loopx-mode-settings label:nth-child(3), .goal-loopx-mode-settings p { grid-column: 1 / -1; } +.goal-loopx-message-mode { display: flex; align-items: center; gap: 8px; margin: 0 0 8px; font-size: 12px; flex-wrap: wrap; } +.goal-loopx-message-mode select { font: inherit; color: inherit; min-height: 44px; border: 1px solid var(--pw-line, #ebebeb); border-radius: 6px; background: var(--pw-surface, #fff); padding: 6px; } +.goal-team-work ul { list-style: none; padding: 0; margin: 8px 0 24px; } +.goal-team-work li { padding: 12px 0; border-bottom: 1px solid var(--pw-line, #ebebeb); overflow-wrap: anywhere; } +.goal-team-bindings li > div, .goal-team-work-actions { display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap; } +.goal-team-work code { display: block; font-size: 12px; overflow-wrap: anywhere; white-space: normal; padding-bottom: 8px; } +.goal-team-work summary { cursor: pointer; padding: 12px 0; min-height: 44px; font-size: 12px; color: var(--pw-muted, #666); } +@media (max-width: 640px) { + .goal-loopx-mode { padding: 6px 14px; } + .goal-loopx-mode-bar { flex-wrap: wrap; gap: 0; } + .goal-loopx-mode-status { min-height: 32px; } + .goal-loopx-mode-actions { margin-left: auto; gap: 2px; } + .goal-loopx-mode-actions button { padding-inline: 8px; } + .goal-loopx-mode-settings { grid-template-columns: 1fr; } + .goal-loopx-dialog-content { padding: 16px; } +} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-loopx-mode.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-loopx-mode.tsx index 419b596542..088e20212d 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-loopx-mode.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-loopx-mode.tsx @@ -1,7 +1,8 @@ -import {useEffect, useState} from "react"; -import {Pause, Play, Settings2} from "lucide-react"; +import {useEffect, useRef, useState} from "react"; +import {Pause, Play, Settings2, Users, X} from "lucide-react"; import {fetchLoopXMode, updateLoopXMode, type LoopXModeSnapshot, type LoopXModeSettings} from "../../data/chat"; import {useWorkspaceI18n} from "./i18n"; +import {GoalTeamWork} from "./goal-team-work"; import "./goal-loopx-mode.css"; export function GoalLoopXMode({sessionId, onPrepare, onExecute, onChange}: { @@ -13,22 +14,30 @@ export function GoalLoopXMode({sessionId, onPrepare, onExecute, onChange}: { const {locale} = useWorkspaceI18n(); const zh = locale === "zh-CN"; const [snapshot, setSnapshot] = useState(null); - const [editing, setEditing] = useState(false); + const [panel, setPanel] = useState<"settings" | "team" | null>(null); + const editing = panel === "settings"; + const setEditing = (value: boolean) => setPanel(value ? "settings" : null); + const dialog = useRef(null); + useEffect(() => { + if (panel && !dialog.current?.open) dialog.current?.showModal(); + else if (!panel && dialog.current?.open) dialog.current.close(); + }, [panel]); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); + const [readError, setReadError] = useState(""); const [settings, setSettings] = useState({agent_id: "", token_budget: 0}); useEffect(() => { let alive = true; let refreshing = false; - setSnapshot(null); onChange(null); setError(""); setEditing(false); + setSnapshot(null); onChange(null); setError(""); setReadError(""); setEditing(false); if (!sessionId || sessionId === "new-session-pending") return; async function refresh() { if (refreshing) return; refreshing = true; try { const result = await fetchLoopXMode(sessionId!); - if (alive) {setSnapshot(result); onChange(result);} - } catch (failure) {if (alive) setError(failure instanceof Error ? failure.message : String(failure));} + if (alive) {setSnapshot(result); onChange(result); setReadError("");} + } catch (failure) {if (alive) setReadError(failure instanceof Error ? failure.message : String(failure));} finally {refreshing = false;} } void refresh(); @@ -72,32 +81,41 @@ export function GoalLoopXMode({sessionId, onPrepare, onExecute, onChange}: { } catch (failure) {setError(failure instanceof Error ? failure.message : String(failure));} finally {setBusy(false);} } + const needsReadback = snapshot?.deliveries.some(row => ["rejected", "unavailable"].includes(row.status)); + const pendingMessages = snapshot?.ingress.filter(row => row.status !== "delivered") ?? []; return
-
{status}

{active - ? (zh ? "协调员持续工作;成员保持独立执行与验收。" : "The coordinator continues; members execute and qualify independently.") - : snapshot?.enabled && native === "complete" ? (zh ? "本次执行已结束;整个 Goal 仍按原标准验收。" : "This run ended; canonical Goal acceptance remains separate.") - : snapshot?.enabled ? (zh ? "暂停不会停止已派发成员;整个 Goal 仍按原标准验收。" : "Pausing retains delegated work; Goal acceptance remains separate.") - : (zh ? "开启后,当前协调员持续推进本 Goal。" : "Enable continued work on this Goal in this conversation.")}

-
-