Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ even when the underlying access was authorized.
- After:
- States and viewports shown:
- Source data: <!-- choose one: none | synthetic | public_fixture -->
- Attention review: <!-- Per docs/development/design.md: what earns its place through high-value information, essential interaction, or expressive visual presentation? Name consolidation/removal across the whole viewport, and how failures, uncertainty, and one-step controls remain available. -->

## Type of Change

Expand Down
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions apps/presentation/dashboard/src/data/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -864,6 +864,27 @@ export type LoopXModeSnapshot = {
export function fetchLoopXMode(sessionId: string) {
return requestJson<LoopXModeSnapshot>(`/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<DelegationInventory>(`/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<DelegationPreflight>(`/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<LoopXModeSnapshot>(`/api/chat/sessions/${sessionId}/loopx`, {
method: "POST", body: JSON.stringify({operation, operation_id: operationId, ...(settings ? {settings} : {})}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,16 @@ const runStatusKey: Record<WorkspaceRun["status"], WorkspaceMessageKey> = {
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 (
<button aria-label={`${t("tasks.viewExecution")}:${run.title}`} className="personal-timeline-row personal-run-row" data-testid="personal-browse-row" onClick={onSelect} type="button">
<span className="personal-row-icon is-run"><Bot size={18} /></span>
<span className="personal-run-identity"><small>{run.goalTitle}</small><strong>{run.agentLabel}</strong></span>
<span className="personal-row-copy"><strong>{run.title}</strong><small>{run.latestActivity}</small></span>
<span className="personal-run-progress" aria-label={`${run.completedSteps}/${run.totalSteps}`}>
<small>{run.completedSteps}/{run.totalSteps}</small><i><b style={{ width: `${progress}%` }} /></i>
</span>
<span className="personal-row-copy"><small>{showGoal ? `${run.goalTitle} · ` : ""}{run.agentLabel}</small><strong>{run.title}</strong>{run.latestActivity !== run.title ? <small>{run.latestActivity}</small> : null}</span>
<span className={`personal-row-status is-${run.status}`}>
{run.status === "running" ? <LoaderCircle className="personal-spin" size={14} /> : null}
{t(runStatusKey[run.status])}
</span>
{run.sessionId ? <span className="personal-run-open-label">{t("tasks.viewExecution")}</span> : null}
<ChevronRight size={17} />
</button>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -121,17 +111,7 @@ export function ChannelHeader({
<button aria-expanded={mobileNavigationOpen ?? false} aria-label={t("header.openGoalNavigation")} className="personal-icon-button personal-mobile-menu" onClick={onOpenNavigation} type="button"><Menu size={18} /></button>
<div className="personal-channel-title">
<h1>{selectedGoal?.title ?? t("header.manager")}</h1>
{!selectedGoal && managerRuntime ? (
<p>{managerRuntime.status === "ready"
? t("header.managerRuntime", {
profile: managerRuntime.runtime_profile,
sandbox: managerRuntime.sandbox,
})
: t("header.managerRuntimeFallback", {
profile: managerRuntime.runtime_profile,
sandbox: managerRuntime.sandbox,
})}</p>
) : null}
{selectedGoal && !selectedGoal.loadState && !["安静运行", "推进中"].includes(selectedGoal.state) ? <p>{localizedGoalState(selectedGoal.state, locale)}</p> : null}
{!selectedGoal && managerChannelBinding ? (
<p className="personal-manager-execution">
<span className={managerExecutionUnavailable ? "personal-execution-chip is-unavailable" : "personal-execution-chip"}>
Expand All @@ -148,16 +128,27 @@ export function ChannelHeader({
})}
</span>
) : null}
{managerExecutionDefaultReason ? (
</p>
) : null}
{!selectedGoal && (managerRuntime || managerExecutionDefaultReason) ? <details className="personal-runtime-details" open={managerRuntime != null && managerRuntime.status !== "ready" ? true : undefined}><summary>{locale === "zh-CN" ? "运行环境" : "Execution environment"}</summary>
{!selectedGoal && managerRuntime ? (
<p>{managerRuntime.status === "ready"
? t("header.managerRuntime", {
profile: managerRuntime.runtime_profile,
sandbox: managerRuntime.sandbox,
})
: t("header.managerRuntimeFallback", {
profile: managerRuntime.runtime_profile,
sandbox: managerRuntime.sandbox,
})}</p>
) : null}
{managerExecutionDefaultReason && managerChannelBinding ? (
<span className="personal-execution-rule-note">
{t(managerExecutionDefaultReason, { executor: managerChannelBinding.executor_endpoint })}
</span>
) : null}
</p>
) : null}
{selectedGoal ? <p>{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}`}</p> : null}
</details> : null}
{selectedGoal?.loadState ? <p role="status">{t(selectedGoal.loadState === "error" ? "startup.goalError" : "startup.goalLoading")}</p> : null}
</div>
{selectedGoal ? (
<div className="personal-goal-navigation">
Expand All @@ -180,7 +171,6 @@ export function ChannelHeader({
<button aria-label={t("header.goalSettings")} title={t("header.goalSettingsDescription")} className="personal-icon-button personal-goal-settings-action" onClick={onOpenGoalCapabilities} type="button"><SlidersHorizontal aria-hidden size={17} /></button>
) : null}
{!selectedGoal ? runtimeControl : null}
<span className="personal-live-indicator"><i />{t("header.live")}</span>
{onRefresh ? (
<span className={`personal-refresh-control is-${refreshState ?? "idle"}`}>
{refreshState === "loading" ? <small>{t("header.refreshing")}</small> : refreshState === "done" ? <small>{t("header.refreshDone")}</small> : refreshState === "error" ? <small>{t("header.refreshFailed")}</small> : null}
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 (
<div className="personal-timeline-empty">
Expand All @@ -44,7 +44,19 @@ export function ChannelTimeline({

const gatedItems = items.filter((item): item is Extract<WorkspaceTimelineItem, { kind: "proposal" }> =>
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<WorkspaceTimelineItem, { kind: "run" }> =>
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<WorkspaceTimelineItem, { kind: "proposal" }> =>
item.kind === "proposal" && item.proposal.status !== "gated");

Expand All @@ -53,7 +65,7 @@ export function ChannelTimeline({
return <AttentionRow attention={item.attention} key={item.id} onSelect={() => onSelect({ item: item.attention, kind: "attention" })} />;
}
if (item.kind === "run") {
return <RunRow key={item.id} onSelect={() => onSelect({ item: item.run, kind: "run" })} run={item.run} />;
return <RunRow showGoal={!selectedGoal} key={item.id} onSelect={() => onSelect({ item: item.run, kind: "run" })} run={item.run} />;
}
if (item.kind === "output") {
return <OutputRow key={item.id} onSelect={() => onSelect({ item: item.output, kind: "output" })} output={item.output} />;
Expand Down Expand Up @@ -89,6 +101,10 @@ export function ChannelTimeline({
<>
<p aria-atomic="true" aria-live="polite" className="personal-live-region" role="status">{liveAnnouncement}</p>
<div className="personal-channel-timeline">
{routineRuns.length ? <details className="personal-activity-summary">
<summary><Activity size={16} aria-hidden="true"/><strong>{locale === "zh-CN" ? "执行动态" : "Execution activity"}</strong><span>{activitySummary}</span></summary>
<div>{routineRuns.map(renderItem)}</div>
</details> : null}
{primaryItems.map(renderItem)}
{gatedItems.length ? (
<details className="personal-gated-summary">
Expand Down
Original file line number Diff line number Diff line change
@@ -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; }
}
Loading
Loading