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
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,7 @@ assert.equal((i18n.match(/"header\.managerOutputTokenBudget"/g) ?? []).length, 2
for (const view of ["Chat", "Tasks", "Files"]) {
assert.match(header, new RegExp(`\"${view.toLowerCase()}\"|>${view}<`), `Goal header exposes ${view}`);
}
assert.match(
page,
/function selectGoal\(goalId: string \| null\)[\s\S]*?setSelectedGoalTab\("tasks"\)/,
"Selecting a Goal opens its Tasks view first",
);
// Task-first entry is exercised with reload/history in workspace-view-recovery.
assert.match(model, /onOpenGoalView\??:/, "Goal detail can switch the center workspace view");
for (const label of ["执行中", "已安排", "等待条件", "可继续"]) {
assert.match(model + drawer + page, new RegExp(label), `Session and Run status language includes ${label}`);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -475,7 +475,8 @@ export type PersonalWorkspaceCallbacks = {
settings?: LoopXModeSettings) => void;
onSelectAgent?: (agentId: string) => void;
onSelectChannel?: (channel: WorkspaceChannel) => void;
onSelectGoal?: (goalId: string | null) => void;
onSelectGoal?: (goalId: string | null, view: WorkspaceGoalTab) => void;
onSelectView?: (view: WorkspaceGoalTab) => void;
onOpenNotificationSettings?: (goalId?: string) => void;
onFetchNotificationTargets?: () => Promise<Array<{ enabled: boolean; provider: string; target_name: string }>>;
onSetupGoalChannel?: (options: { execute: boolean; goalId: string; target: string }) => Promise<{ ok: boolean; blocker?: string; public_summary?: string; status?: string }>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -757,7 +757,7 @@ export function PersonalWorkspacePage({
agents = [{ agentId: "codex", available: true, capability: "代码与项目执行", label: "Codex" }],
callbacks = {},
goalArchiveLoadState = { error: null, phase: "ready" },
initialManagerChatOpen = false,
selectedView: controlledView,
managerChannelBinding,
managerRuntime,
model,
Expand All @@ -776,7 +776,7 @@ export function PersonalWorkspacePage({
agents?: WorkspaceAgentOption[];
callbacks?: PersonalWorkspaceCallbacks;
goalArchiveLoadState?: WorkspaceGoalArchiveLoadState;
initialManagerChatOpen?: boolean;
selectedView?: WorkspaceGoalTab;
managerChannelBinding?: ManagerChannelBinding | null;
managerRuntime?: ManagerRuntimeSessionReadback | null;
model: WorkspaceModel;
Expand All @@ -794,8 +794,13 @@ export function PersonalWorkspacePage({
const [taskInspectorExpanded, setTaskInspectorExpanded] = useState(false);
const [activeSessionRun, setActiveSessionRun] = useState<WorkspaceRun | null>(null);
const [proposals, setProposals] = useState<Record<string, WorkspaceActionPreview>>({});
const [selectedGoalTab, setSelectedGoalTab] = useState<WorkspaceGoalTab>("chat");
const [managerChatOpen, setManagerChatOpen] = useState(initialManagerChatOpen);
const [localView, setLocalView] = useState<WorkspaceGoalTab>(controlledGoalId ? "chat" : "overview");
const selectedGoalTab = controlledView ?? localView;
const managerChatOpen = selectedGoalTab === "chat";
function setSelectedGoalTab(view: WorkspaceGoalTab) {
setLocalView(view);
callbacks.onSelectView?.(view);
}
const [managerConversationReceiptVisible, setManagerConversationReceiptVisible] = useState(false);
const [goalConversationReceiptVisible, setGoalConversationReceiptVisible] = useState(false);
const [drafts, setDrafts] = useState<Record<string, string>>(() => {
Expand Down Expand Up @@ -1523,8 +1528,8 @@ export function PersonalWorkspacePage({
const drawerCallbacks: PersonalWorkspaceCallbacks = {
...callbacks,
onOpenRunSession: async (run) => {
if (run.goalId !== selectedGoalId) selectGoal(run.goalId);
setSelectedGoalTab("chat");
if (run.goalId !== selectedGoalId) selectGoal(run.goalId, "chat");
else setSelectedGoalTab("chat");
await callbacks.onOpenRunSession?.(run);
setActiveSessionRun(run);
setSelection(null);
Expand All @@ -1539,8 +1544,8 @@ export function PersonalWorkspacePage({
setSelection(null);
},
onOpenOutput: (output) => {
if (output.goalId !== selectedGoalId) selectGoal(output.goalId);
setSelectedGoalTab("files");
if (output.goalId !== selectedGoalId) selectGoal(output.goalId, "files");
else setSelectedGoalTab("files");
callbacks.onOpenOutput?.(output);
},
onApplyProposal: applyProposal,
Expand Down Expand Up @@ -1619,16 +1624,15 @@ export function PersonalWorkspacePage({
onOpenOutput: drawerCallbacks.onOpenOutput,
} : drawerCallbacks;

function selectGoal(goalId: string | null) {
function selectGoal(goalId: string | null, view: WorkspaceGoalTab = goalId ? "tasks" : "overview") {
setLocalGoalId(goalId);
setManagerChatOpen(false);
setManagerConversationReceiptVisible(false);
setGoalConversationReceiptVisible(false);
setActiveSessionRun(null);
setSelection(null);
setSelectedGoalTab("tasks");
setLocalView(view);
setMobileSidebarOpen(false);
callbacks.onSelectGoal?.(goalId);
callbacks.onSelectGoal?.(goalId, view);
}

function selectAgent(agentId: string) {
Expand Down Expand Up @@ -1836,15 +1840,15 @@ export function PersonalWorkspacePage({
onOpenNavigation={() => setMobileSidebarOpen(true)}
onOpenManagerChat={() => {
setManagerConversationReceiptVisible(false);
setManagerChatOpen(true);
setSelectedGoalTab("chat");
}}
onSelectGoalTab={(tab) => {
if (tab === "chat") openGoalConversation();
else setSelectedGoalTab(tab);
}}
onSelectAgent={selectAgent}
onReturnManagerHome={() => {
setManagerChatOpen(false);
setSelectedGoalTab("overview");
setManagerConversationReceiptVisible(false);
window.requestAnimationFrame(() => channelScrollRef.current?.scrollTo({ behavior: "smooth", top: 0 }));
}}
Expand Down Expand Up @@ -1956,7 +1960,7 @@ export function PersonalWorkspacePage({
onInterruptTurn={!readOnly && callbacks.onInterruptConversationTurn
? (turnId) => callbacks.onInterruptConversationTurn!("manager", turnId)
: undefined}
onOpenGoalEvidence={(goalId) => { selectGoal(goalId); openGoalConversation(); }} />
onOpenGoalEvidence={(goalId) => { selectGoal(goalId, "chat"); }} />
)}
</div>
<div className="personal-composer-wrap">
Expand Down Expand Up @@ -1984,7 +1988,7 @@ export function PersonalWorkspacePage({
onClose={() => setManagerConversationReceiptVisible(false)}
onOpenConversation={() => {
setManagerConversationReceiptVisible(false);
setManagerChatOpen(true);
setSelectedGoalTab("chat");
}} />
) : null}
{selectedGoal && selectedGoalTab !== "chat" && goalConversationReceiptVisible && goalMessages.length ? (
Expand Down
2 changes: 1 addition & 1 deletion apps/presentation/dashboard/src/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { AnswerReportPage } from "./features/personal-workspace/answer-report-pa
const searchSchema = z.object({
goalId: z.string().optional().default(""),
statusUrl: z.string().optional().default(""),
view: z.literal("conversation").optional(),
view: z.enum(["conversation", "overview", "tasks", "files"]).optional(),
reportSessionId: z.string().regex(/^[A-Za-z0-9._-]{1,160}$/).optional(),
reportMessageId: z.string().regex(/^[A-Za-z0-9._-]{1,160}$/).optional(),
});
Expand Down
53 changes: 26 additions & 27 deletions apps/presentation/dashboard/src/views/dashboard-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ import {
type WorkspaceAgentOption,
type WorkspaceAttention,
type WorkspaceGoal,
type WorkspaceGoalTab,
type WorkspaceGoalUsage,
type WorkspaceHomeLane,
type WorkspaceImageAttachment,
Expand Down Expand Up @@ -1245,7 +1246,8 @@ function buildPersonalHomeModel(
}
function PersonalGoalHome({
goalArchiveLoadState,
initialManagerChatOpen,
selectedView,
onSelectView,
isLoading,
onGoalActivationStateChange,
onGoalDeleted,
Expand All @@ -1262,11 +1264,12 @@ function PersonalGoalHome({
toggleTheme,
}: {
goalArchiveLoadState: WorkspaceGoalArchiveLoadState;
initialManagerChatOpen: boolean;
selectedView?: WorkspaceGoalTab;
onSelectView: (view: WorkspaceGoalTab) => void;
isLoading: boolean;
onGoalActivationStateChange: (goalId: string, activationState: "active" | "stopped") => void;
onGoalDeleted: (goalId: string) => void;
onSelectGoal: (goalId: string) => void;
onSelectGoal: (goalId: string, view?: WorkspaceGoalTab) => void;
onReconcileStatus: (options?: { invalidateGoalIds?: string[] }) => void | Promise<void>;
onRefresh: (scope?: WorkspaceReadScope) => void | Promise<void>;
onRetryGoalArchive: () => void | Promise<void>;
Expand Down Expand Up @@ -2619,18 +2622,8 @@ function PersonalGoalHome({
setAgentMenuOpen(false);
}

function openManagerChat() {
onSelectGoal("");
setMobilePanel("chat");
}

function openGoalList() {
onSelectGoal("");
setMobilePanel("goals");
}

function openGoalChat(goalId: string) {
onSelectGoal(goalId);
onSelectGoal(goalId, "chat");
setMobilePanel("chat");
}

Expand Down Expand Up @@ -2966,7 +2959,6 @@ function PersonalGoalHome({
return { ...current, [targetContextId]: [...messages, { id, sourceMessageId: `steer:${ingressId}`, sourceTurnId: turnId, lines: [], role: "user", text: message }] };
});
},
onOpenGoal: openGoalChat,
onOpenRunSession: async (run) => {
if (!run.sessionId) return;
const sessionId = run.sessionId;
Expand All @@ -2975,7 +2967,6 @@ function PersonalGoalHome({
...current,
[sessionId]: snapshot,
}));
openGoalChat(run.goalId);
setMessagesByContext((current) => ({
...current,
[run.goalId]: snapshot.messages.map((message) => ({
Expand All @@ -2999,7 +2990,6 @@ function PersonalGoalHome({
turnId: snapshot.session.active_turn_id ?? undefined,
});
},
onOpenOutput: (output) => openGoalChat(output.goalId),
...(goalSubagentConfigurationEnabled ? {
onPreviewGoalSubagentConfiguration: async (request) => {
const preview = await previewGoalSubagentConfiguration(request);
Expand Down Expand Up @@ -3085,14 +3075,15 @@ function PersonalGoalHome({
},
onRetryResumeRun: retryManagerSession,
onSelectAgent: chooseAgent,
onSelectGoal: (goalId) => goalId ? openGoalChat(goalId) : openManagerChat(),
onSelectGoal: (goalId, view) => { onSelectGoal(goalId ?? "", view); setMobilePanel("chat"); },
onSelectView,
onSendMessage: async (message, agentId, goalId, attachments) => sendManagerQuestion(message, { agentId, goalId, attachments }),
onPrepareLoopX: (agentId, goalId) => prepareGoalConversation(goalId, agentId),
onStartLoopX: (operation, agentId, goalId, settings) => { void sendManagerQuestion(operation === "start" ? "开启 LoopX 模式,持续推进当前 Goal。" : "恢复 LoopX 模式。", {agentId, goalId, loopxMode: {operation, settings}}); },
onStartNewRunSession: startNewManagerSession,
}}
goalArchiveLoadState={goalArchiveLoadState}
initialManagerChatOpen={initialManagerChatOpen}
selectedView={selectedView}
managerChannelBinding={managerChannelBinding}
managerRuntime={managerRuntime}
conversationSessionId={runtimeBindings[contextId]?.sessionId}
Expand Down Expand Up @@ -3303,12 +3294,16 @@ export function DashboardPage() {
setPayload(nextPayload);
setSource(nextSource);
setStatusUrl(url);
await navigate({
search: (current) => ({
...current,
statusUrl: url,
}),
});
// Loading the current source on reload must not add a duplicate history
// entry: Back should return to the user's previous workspace view.
if (search.statusUrl !== url) {
await navigate({
search: (current) => ({
...current,
statusUrl: url,
}),
});
}
if (!statusRequestIsCurrent(statusRequestFenceRef.current, request)) return false;
statusRequestFenceRef.current.requestedUrl = null;
setRequestedStatusUrl(null);
Expand Down Expand Up @@ -3549,11 +3544,12 @@ export function DashboardPage() {
}
}, [search.goalId, isLoading, progress, source]);

function selectGoal(goalId: string) {
function selectGoal(goalId: string, view?: WorkspaceGoalTab) {
void navigate({
search: (current) => ({
...current,
goalId,
view: view === "chat" ? "conversation" : view,
}),
});
}
Expand All @@ -3574,7 +3570,10 @@ export function DashboardPage() {
return (
<PersonalGoalHome
goalArchiveLoadState={goalArchiveLoadState}
initialManagerChatOpen={search.view === "conversation" && !search.goalId}
selectedView={search.view === "conversation" ? "chat" : search.goalId ? search.view ?? "chat" : "overview"}
onSelectView={(view) => {
void navigate({ search: current => ({ ...current, view: view === "chat" ? "conversation" : view }) });
}}
isLoading={isLoading}
onGoalActivationStateChange={(goalId, activationState) => {
statusRequestFenceRef.current.projectionRevision += 1;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,8 @@ Artifact continuity is part of semantic continuity. Reuse the artifact owner to

Long-running work must remain legible in the existing conversation: what is being attempted, who owns the next step, what is actually blocking, and what conclusion is owed. Provide expandable tool/artifact activity and current semantic context in the frontend; Lark gets a concise equivalent and actionable results. Distinguish a queued worker, unavailable host, permission refusal, website login and undelivered answer. Do not expose raw protocol envelopes or claim fine-grained tool activity on adapters that cannot supply it.

The App must also preserve the user's chosen work view across reload and browser history. Manager conversation and Goal overview/tasks/conversation/artifacts use the existing typed workspace route; source loading must not insert duplicate history entries. Preserve the source and Goal identity, retain an unsent draft under its existing owner, and never start or replay a Turn merely to restore navigation. Fresh-entry defaults remain distinct from restoring an explicit view. Packaged navigation recovery qualifies this presentation boundary only; actual receiver adoption, steering and owed-result return retain their independent acceptance requirements.

Reuse existing capability instructions, context hooks, memory and scheduling owners. Reusable methods may inform planning and handoff, with their source/version visible; remembered lessons do not replace accepted task state or current verification. Stable recurring work may use the existing schedule/event path after its task and replay behavior are understood. This RFC does not add a routine engine, compulsory method learning, a new hook family or business-specific automation.

Parallel work does not imply isolated execution resources. Reuse runtime serialization/leases for shared browser screens or mutable workspaces, separately from Core work ownership. Separate screens, agent names or conversation tabs are not permission boundaries. If a runtime lacks the required coordination, serialize the affected operation and expose the wait; unrelated evidence work can continue. M1 reports actual resource behavior rather than assuming a shared cloud-computer design.
Expand Down
6 changes: 6 additions & 0 deletions docs/architecture/rfcs/loopx-overall-roadmap-v0.md
Original file line number Diff line number Diff line change
Expand Up @@ -644,6 +644,12 @@ callback; neither may appear as work completed or a second user-visible reply.
This uses the R3 collaboration owner and current managed Turn wake instead of
a separate steward scheduler.

For App continuity in GQ07–GQ09, qualify chosen Manager/Goal view restoration
through reload and browser Back/Forward using the existing typed route and
draft owner. Keep source/Goal identity and prove navigation causes no work
mutation or Turn replay. This bounded presentation qualification does not
close R3's receiver-adoption, steering or original-route return acceptance.

R3 entry qualification also covers ordinary human messages in an explicitly
configured steward group without a mention. The shared TS admission rule owns
trigger semantics; Lark supplies provider identity and the existing inbox owns
Expand Down
2 changes: 2 additions & 0 deletions examples/personal-workspace-browser-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { chatRecoveryScenario } from "./personal-workspace-browser/chat-recovery
import { composerSessionAdmissionScenario } from "./personal-workspace-browser/composer-session-admission.mjs";
import { attachedHostFollowUpScenario } from "./personal-workspace-browser/attached-host-follow-up.mjs";
import { conversationReturnContinuityScenario } from "./personal-workspace-browser/conversation-return-continuity.mjs";
import { workspaceViewRecoveryScenario } from "./personal-workspace-browser/workspace-view-recovery.mjs";
import { conversationHistoryRecoveryScenario } from "./personal-workspace-browser/conversation-history-recovery.mjs";
import { executionChipScenario } from "./personal-workspace-browser/execution-chip.mjs";
import {
Expand Down Expand Up @@ -55,6 +56,7 @@ import { conversationStartupScenario } from "./personal-workspace-browser/conver

const scenarioCatalog = [conversationStartupScenario,goalDraftScenario, capabilityScopeScenario, stewardGroupTriggerScenario, conversationInputScenario, goalActivityScenario, conversationActivityScenario, navigationSortingScenario, automationCadenceScenario, chatRecoveryScenario, composerSessionAdmissionScenario, attachedHostFollowUpScenario, conversationReturnContinuityScenario, conversationHistoryRecoveryScenario, answerPresentationScenario, loopxModeScenario, teamEvidenceScenario, managedGoalResultsScenario, typedActionsScenario, teamPlanScenario, stewardJourneyScenario, executionChipScenario, stewardModelSettingsScenario, progressiveLoadingScenario, workspaceLocaleScenario, newestDraftScenario, larkCliMissingScenario, executionServiceOfflineScenario];
scenarioCatalog.push(confirmedOperationsScenario);
scenarioCatalog.push(workspaceViewRecoveryScenario);
const requestedScenario = process.env.LOOPX_PERSONAL_WORKSPACE_SCENARIO;
const scenarios = requestedScenario
? scenarioCatalog.filter((scenario) => scenario.id === requestedScenario)
Expand Down
Loading
Loading