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 apps/presentation/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
"test:conversation-returns": "node --experimental-strip-types src/data/conversation-returns.test.mjs",
"smoke:conversation-history": "vite build --ssr smoke/conversation-history-smoke.ts --outDir node_modules/.cache/loopx-conversation-history --emptyOutDir && node node_modules/.cache/loopx-conversation-history/conversation-history-smoke.js",
"smoke:conversation-returns-packaged": "LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/conversation-return-browser-smoke.mjs",
"smoke:workspace-scope-packaged": "LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/workspace-scope-browser-smoke.mjs",
"smoke:recent-completions": "tsc --ignoreConfig --target ES2022 --module CommonJS --moduleResolution Node --ignoreDeprecations 6.0 --resolveJsonModule --esModuleInterop --jsx react-jsx --skipLibCheck --strict --types node --outDir /tmp/loopx-recent-completions-smoke smoke/recent-completions-smoke.ts && NODE_PATH=\"$PWD/node_modules\" node /tmp/loopx-recent-completions-smoke/apps/presentation/dashboard/smoke/recent-completions-smoke.js"
},
"dependencies": {
Expand Down
103 changes: 103 additions & 0 deletions apps/presentation/dashboard/smoke/workspace-scope-browser-smoke.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// Packaged steward conversation scoped to a host-granted workspace, against the
// production Chat server and store. A synthetic workspace and the repository's
// fake Codex app-server stand in for the host; no model, registry or Goal is used.
import assert from "node:assert/strict";
import { spawn, spawnSync } from "node:child_process";
import { mkdtemp, mkdir, rename, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { resolveTestPython } from "../../../../scripts/test-python.mjs";
import { launchBrowser, loadPlaywright, waitForHttp } from "../../../../examples/dashboard-browser-smoke-support.mjs";

const repoRoot = resolve(import.meta.dirname, "../../../..");
const python = resolveTestPython({ repoRoot });
const port = Number(process.env.LOOPX_WORKSPACE_SCOPE_PORT ?? 5413);
const screenshots = process.env.LOOPX_WORKSPACE_SCOPE_SCREENSHOTS;
const root = await mkdtemp(join(tmpdir(), "loopx-workspace-scope-"));
const workspace = join(root, "notes");
await mkdir(workspace);
await mkdir(join(root, "home"));
const codex = join(root, "codex");
const written = spawnSync(python, ["-c", [
"import pathlib, runpy, sys",
"source = runpy.run_path('examples/loopx-chat-runtime-smoke.py')['FAKE_CODEX']",
"path = pathlib.Path(sys.argv[1]); path.write_text(source); path.chmod(0o700)",
].join("\n"), codex], { cwd: repoRoot, encoding: "utf8" });
assert.equal(written.status, 0, written.stderr);

const server = spawn(python, ["-c", "import sys; from loopx.entrypoint import main; sys.exit(main())",
"chat", "--no-open", "--port", String(port), "--codex-bin", codex, "--scan-root", workspace, "--global-registry"],
{ cwd: root, env: { ...process.env, HOME: join(root, "home"), PYTHONPATH: repoRoot }, stdio: ["ignore", "pipe", "pipe"] });
let serverError = "";
for (const stream of [server.stdout, server.stderr]) stream.on("data", (chunk) => { serverError += String(chunk); });
// The first-run usage notice is unrelated to this journey; keep evidence focused.
async function capture(target, name) {
if (!screenshots) return;
const dismiss = target.getByRole("button", { name: "收起统计告知" });
if (await dismiss.isVisible()) await dismiss.click();
await target.screenshot({ path: join(screenshots, name) });
}
let browser;
try {
const url = `http://127.0.0.1:${port}/chat/`;
await waitForHttp(url).catch((error) => { throw new Error(`${error.message}\n${serverError}`); });
browser = await launchBrowser(loadPlaywright().chromium);
const page = await browser.newPage({ locale: "zh-CN", viewport: { width: 1440, height: 900 } });
const sessionRequests = [];
page.on("request", (request) => {
if (request.method() === "POST" && new URL(request.url()).pathname === "/api/chat/sessions") sessionRequests.push(request.postDataJSON());
});
await page.goto(url, { waitUntil: "networkidle" });
const scope = page.getByRole("combobox", { name: "范围" });
assert.equal(await scope.count(), 0, "The steward overview keeps its first screen; scope belongs to the conversation");

await page.getByRole("navigation", { name: "管家视图" }).getByRole("button", { name: "对话" }).click();
await scope.click();
await page.getByRole("option", { name: "notes" }).click();
await page.getByText("工作区对话 · 只读").waitFor();
const workspaceUrl = page.url();
assert.match(workspaceUrl, /[?&]workspace=[0-9a-f]{24}(?:&|$)/u);
const question = "整理一下最近的素材";
await page.getByLabel("发送消息").fill(question);
await page.keyboard.press("Enter");
await page.getByText("Runtime response.").first().waitFor({ timeout: 15_000 });
await capture(page, "ordinary-workspace-conversation.png");

const projectRequests = sessionRequests.filter((body) => body.context_kind === "project");
assert.ok(projectRequests.length > 0, "The workspace scope opens a project Session");
for (const body of projectRequests) {
assert.equal("goal_id" in body, false, "A workspace Session never names a Goal");
assert.match(body.project_ref, /^[0-9a-f]{24}$/u);
}

await page.reload({ waitUntil: "networkidle" });
await page.getByText(question).first().waitFor({ timeout: 15_000 });
await scope.click();
await page.getByRole("option", { name: "LoopX 管家" }).click();
await page.waitForURL((current) => !current.searchParams.has("workspace"));
await page.getByRole("combobox", { name: "范围" }).waitFor();
assert.equal(await page.getByText(question).count(), 0, "The steward scope does not show the workspace exchange");
assert.ok(sessionRequests.filter((body) => body.context_kind === "manager").every((body) => !("project_ref" in body)));

if (screenshots) {
const narrow = await browser.newPage({ locale: "zh-CN", viewport: { width: 390, height: 844 } });
await narrow.goto(workspaceUrl, { waitUntil: "networkidle" });
await narrow.getByText(question).first().waitFor({ timeout: 15_000 });
await capture(narrow, "ordinary-workspace-conversation-narrow.png");
await narrow.close();
}

await rename(workspace, `${workspace}.moved`);
await page.goto(workspaceUrl, { waitUntil: "networkidle" });
await page.getByTestId("workspace-scope-unavailable").waitFor({ timeout: 15_000 });
await page.getByText(question).first().waitFor({ timeout: 15_000 });
await page.getByLabel("发送消息").fill("还能继续吗");
assert.equal(await page.getByRole("button", { name: "发送", exact: true }).isDisabled(), true,
"A revoked grant keeps history and blocks new messages");
await capture(page, "ordinary-workspace-grant-rejection.png");
console.log("workspace scope browser smoke ok");
} finally {
await browser?.close();
server.kill();
await rm(root, { force: true, recursive: true });
}
2 changes: 2 additions & 0 deletions apps/presentation/dashboard/src/data/chat-model.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { GoalDraft } from "../../../../../loopx/control_plane/collaboration/goal_draft.js";
export type LoopXModeSettings = { agent_id: string; token_budget: number };
/** A host-granted workspace an ordinary conversation may be scoped to. */
export type ChatProject = { project_ref: string; title: string; grant: "workspace_read" };

export type ChatTodo = {
todo_id: string | null;
Expand Down
47 changes: 42 additions & 5 deletions apps/presentation/dashboard/src/data/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
todoApplyResultMatchesRequest,
todoPreviewMatchesRequest,
type AgentResponse,
type ChatProject,
type CollaborationReadback,
type LoopXModeSettings,
type TodoApplyResult,
Expand Down Expand Up @@ -641,16 +642,42 @@ export async function recordProjectionExchange(options: {
);
}

/** The App's conversation targets; each owns exactly one Core Chat channel. */
export type ConversationContext =
| { kind: "manager" }
| { kind: "goal"; goalId: string }
| { kind: "project"; projectRef: string };

// Goal ids are single path segments, so a key containing "/" never names a Goal.
const PROJECT_CONVERSATION_KEY_PREFIX = "project/";

export function conversationContextKey(context: ConversationContext): string {
if (context.kind === "manager") return "manager";
return context.kind === "goal" ? context.goalId : `${PROJECT_CONVERSATION_KEY_PREFIX}${context.projectRef}`;
}

export function conversationContextOfKey(key: string): ConversationContext {
if (key === "manager") return { kind: "manager" };
if (key.startsWith(PROJECT_CONVERSATION_KEY_PREFIX)) {
return { kind: "project", projectRef: key.slice(PROJECT_CONVERSATION_KEY_PREFIX.length) };
}
return { kind: "goal", goalId: key };
}

export function conversationChannelId(context: ConversationContext): string {
if (context.kind === "manager") return "manager";
return context.kind === "goal" ? `goal.${context.goalId}` : `project.${context.projectRef}`;
}

export async function createChatSession(
goalId: string,
context: ConversationContext,
agentId?: string,
mode: "resume_latest" | "new" = "resume_latest",
contextKind: "goal" | "manager" = "goal",
signal?: AbortSignal,
) {
return requestJson<{
agent_id: string;
goal_id: string;
goal_id: string | null;
ok: true;
resumed: boolean;
session_id: string;
Expand All @@ -660,7 +687,10 @@ export async function createChatSession(
// An omitted ``agent_id`` means "no explicit executor pick": the channel
// owner resolves its own default. Sending this client's own default would
// silently re-point the steward channel away from its configured executor.
body: JSON.stringify({ goal_id: goalId, agent_id: agentId, mode, context_kind: contextKind }),
// A project Session carries only the host-issued reference, never a Goal.
body: JSON.stringify(context.kind === "project"
? { context_kind: "project", project_ref: context.projectRef, agent_id: agentId, mode }
: { goal_id: context.kind === "goal" ? context.goalId : "", agent_id: agentId, mode, context_kind: context.kind }),
});
}

Expand All @@ -674,7 +704,8 @@ export type ChatStreamEvent = {

export type ChatSessionSummary = {
session_id: string;
goal_id: string;
goal_id: string | null;
project_ref?: string | null;
agent_id: string;
adapter_kind: string;
channel_id?: string;
Expand All @@ -692,6 +723,12 @@ export type ChatSessionSummary = {
manager_runtime?: ManagerRuntimeSessionReadback | null;
};

export type { ChatProject };

export async function fetchChatProjects(signal?: AbortSignal) {
return requestJson<{ok: true; projects: ChatProject[]}>("/api/chat/projects", {signal});
}

/** ``chat_store`` Session modes; an omitted mode is a managed runtime Session. */
export type ChatSessionMode = "managed_runtime" | "attached_host";

Expand Down
16 changes: 16 additions & 0 deletions apps/presentation/dashboard/src/data/use-chat-projects.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { useQuery } from "@tanstack/react-query";
import { fetchChatProjects, type ChatProject } from "./chat";

/** Workspaces the host currently grants to ordinary conversations. The
* session re-checks its grant before every Turn; this list only offers scopes. */
export function useChatProjects(readOnly: boolean): { projects: ChatProject[] | null; readFailed: boolean } {
const query = useQuery({
queryKey: ["chat-projects"],
queryFn: ({ signal }) => fetchChatProjects(AbortSignal.any([signal, AbortSignal.timeout(10_000)])),
enabled: !readOnly,
refetchOnWindowFocus: "always",
retry: false,
});
if (readOnly) return { projects: [], readFailed: false };
return { projects: query.data?.projects ?? null, readFailed: query.isError };
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Bot, Eye, Menu, RefreshCw, SlidersHorizontal } from "lucide-react";
import { Bot, Eye, FolderOpen, Menu, RefreshCw, SlidersHorizontal } from "lucide-react";
import { GoalActivityChip } from "./goal-activity-view";

import { localizedGoalState, useWorkspaceI18n } from "./i18n";
Expand All @@ -8,6 +8,7 @@ import { WorkspaceSelect } from "./workspace-select";

export function ChannelHeader({
agents,
conversationScope,
managerChannelBinding,
managerChatOpen,
managerRuntime,
Expand All @@ -25,8 +26,13 @@ export function ChannelHeader({
selectedAgentId,
selectedGoal,
selectedGoalTab,
workspaceGrantLabel,
}: {
agents: WorkspaceAgentOption[];
/** Steward conversation scope; absent when the host grants no workspace. */
conversationScope?: { options: { disabled?: boolean; label: string; value: string }[]; value: string; onChange: (value: string) => void } | null;
/** Set while the steward conversation is scoped to a workspace. */
workspaceGrantLabel?: string | null;
managerChannelBinding?: ManagerChannelBinding | null;
managerChatOpen?: boolean;
managerRuntime?: ManagerRuntimeSessionReadback | null;
Expand Down Expand Up @@ -144,13 +150,29 @@ export function ChannelHeader({
{onOpenManagerSettings ? <SlidersHorizontal aria-hidden size={12} /> : null}
</> : null;

// The steward channel binding describes the steward executor; a workspace
// conversation runs on the selected runtime instead.
const stewardBindingVisible = !selectedGoal && !workspaceGrantLabel;
const scopeControl = !selectedGoal && managerChatOpen && conversationScope && !readOnlySourceLabel ? (
<WorkspaceSelect
ariaLabel={t("header.conversationScope")}
className="personal-agent-select personal-scope-select"
icon={<FolderOpen size={16} />}
onChange={conversationScope.onChange}
options={conversationScope.options}
prefixLabel={t("header.conversationScope")}
value={conversationScope.value}
/>
) : null;

return (
<header className="personal-channel-header" data-goal-selected={Boolean(selectedGoal)}>
<header className="personal-channel-header" data-goal-selected={Boolean(selectedGoal)} data-workspace-scope={workspaceGrantLabel ? "true" : undefined}>
<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 && !selectedGoal.loadState ? <p className="personal-channel-activity"><GoalActivityChip goal={selectedGoal} /></p> : null}
{!selectedGoal && managerChannelBinding ? (
{workspaceGrantLabel ? <p className="personal-channel-activity">{t("workspace.conversation", { grant: workspaceGrantLabel })}</p> : null}
{stewardBindingVisible && managerChannelBinding ? (
<p className="personal-manager-execution">
{onOpenManagerSettings ? <button aria-label={t("header.managerModelSettings")} title={t("header.managerModelSettings")}
className={executionChipClass} onClick={onOpenManagerSettings} type="button">{executionChipContent}</button>
Expand All @@ -165,7 +187,7 @@ export function ChannelHeader({
) : null}
</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>
{stewardBindingVisible && (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", {
Expand Down Expand Up @@ -211,6 +233,7 @@ export function ChannelHeader({
{selectedGoal && onOpenGoalCapabilities ? (
<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}
{scopeControl}
{!selectedGoal ? runtimeControl : null}
{onRefresh ? (
<span className={`personal-refresh-control is-${refreshState ?? "idle"}`}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ function answerLink(sessionId: string, messageId: string) {


export function ChannelTimeline({
emptyState,
items,
onSelect,
selectedGoal,
Expand All @@ -40,6 +41,7 @@ export function ChannelTimeline({
onSteerTurn,
onCancelPreparation,
}: {
emptyState?: { title: string; description: string };
items: WorkspaceTimelineItem[];
onCancelPreparation?: () => void;
onSelect: (selection: WorkspaceDrawerSelection) => void;
Expand All @@ -56,8 +58,8 @@ export function ChannelTimeline({
return (
<div className="personal-timeline-empty">
<span><Sparkles size={20} /></span>
<strong>{selectedGoal ? t("timeline.emptyGoal") : t("timeline.emptyWorkspace")}</strong>
<p>{selectedGoal ? t("timeline.emptyGoalDescription") : t("timeline.emptyWorkspaceDescription")}</p>
<strong>{emptyState?.title ?? (selectedGoal ? t("timeline.emptyGoal") : t("timeline.emptyWorkspace"))}</strong>
<p>{emptyState?.description ?? (selectedGoal ? t("timeline.emptyGoalDescription") : t("timeline.emptyWorkspaceDescription"))}</p>
</div>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export type WorkspaceGoalExecution =
| { kind: "unknown" };

export type GoalSessionFact = {
goal_id: string;
goal_id: string | null;
agent_id: string;
active_turn_id: string | null;
host_surface?: string | null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1150,6 +1150,15 @@ const en = {
"sidebar.delete": "Delete",
"sidebar.deleteGoal": "Delete Goal",
"sidebar.manager": "LoopX Manager",
"header.conversationScope": "Scope",
"header.scopeWorkspacesUnavailable": "Workspaces could not be read",
"workspace.conversation": "Workspace conversation · {grant}",
"workspace.grantRead": "read-only",
"workspace.grantRevoked": "no longer authorized",
"workspace.unknownTitle": "Unavailable workspace",
"workspace.unavailable": "This host no longer authorizes this workspace. The history is kept; new messages will be rejected until it is authorized again.",
"workspace.empty": "Talk about this workspace with continuous context. An ordinary conversation does not create a Goal or include other workspaces.",
"workspace.placeholder": "Ask about {workspace}…",
"sidebar.notifications": "Settings",
"sidebar.owner": "Personal workspace",
"sidebar.product": "Personal Agent workspace",
Expand Down Expand Up @@ -2430,6 +2439,15 @@ const zhCN: Record<WorkspaceMessageKey, string> = {
"sidebar.delete": "删除",
"sidebar.deleteGoal": "删除 Goal",
"sidebar.manager": "LoopX 管家",
"header.conversationScope": "范围",
"header.scopeWorkspacesUnavailable": "暂时无法读取工作区",
"workspace.conversation": "工作区对话 · {grant}",
"workspace.grantRead": "只读",
"workspace.grantRevoked": "已不再授权",
"workspace.unknownTitle": "不可用的工作区",
"workspace.unavailable": "此宿主已不再授权这个工作区。历史记录会保留;重新授权前,新消息会被拒绝。",
"workspace.empty": "围绕这个工作区对话,保留连续上下文。普通对话不会创建 Goal,也不会带入其他工作区。",
"workspace.placeholder": "询问 {workspace}…",
"sidebar.notifications": "设置",
"sidebar.owner": "个人工作区",
"sidebar.product": "个人 Agent 工作区",
Expand Down
Loading
Loading