正在读取组织…
+电脑上的 RoleWeave 必须已经开着。配对一次后,这里发的字会立刻交给绑定员工。
+ + +尚未配对。
+ +招聘、改组织、跑回合仍在电脑上的 RoleWeave 桌面应用里完成。手机壳只负责看组织和发一条指令。
+-
+
- + 入口 + 手机壳优先 + +
- + 组织数据 + 示例工作区只读预览 + +
- + 回合 + 在已打开的电脑桌面执行 + +
From 334b7f3a93fd77cf88b1d547fc0b2dd0519e6350 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8B=92=E5=B8=83=E6=9C=97-=E8=A9=B9=E5=A7=86=E6=96=AF?= <2986253039@qq.com> Date: Fri, 18 Sep 2026 00:49:12 +0800 Subject: [PATCH 1/2] feat(mobile): add a native phone shell for organization preview (#329) Phone user agents get a thumb-first org/command/settings surface. Turns still run on the desktop app. --- CHANGELOG.md | 3 + apps/mobile-web/app.css | 213 +++++++++++++++++++++++++++++++ apps/mobile-web/app.mjs | 193 ++++++++++++++++++++++++++++ apps/mobile-web/index.html | 87 +++++++++++++ apps/mobile-web/surface.mjs | 100 +++++++++++++++ docs/design/mobile-shell.md | 32 +++++ scripts/mobile-web.mjs | 78 +++++++++++ scripts/test/mobile-web.test.mjs | 78 +++++++++++ 8 files changed, 784 insertions(+) create mode 100644 apps/mobile-web/app.css create mode 100644 apps/mobile-web/app.mjs create mode 100644 apps/mobile-web/index.html create mode 100644 apps/mobile-web/surface.mjs create mode 100644 docs/design/mobile-shell.md create mode 100644 scripts/mobile-web.mjs create mode 100644 scripts/test/mobile-web.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c6f609c..8717fd09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ ### Added +- #329:手机打开 RoleWeave 时进入原生手机壳(组织 / 指令 / 桌面 / 设置),而不是缩过的桌面工作台。组织页只读预览 `examples/oss-maintainer`;回合仍在已打开的电脑桌面执行。 + + - #309 后续:控制面拒绝超大请求体时,drain 或读取被中止(2 秒截止、10 MiB drain 上限、对端断开)会向 stderr 写一行原因与字节数,现场 EPIPE / 连接复位事故从此可归因;同步 docs/api-contract-v0.md:1 MiB 上限补记先读后拒行为、drain 上限与截止、server requestTimeout / headersTimeout,以及拒绝响应携带 Connection: close。附测试:stall 的超大上传在 drain 与 read 两条路径都断言中止行。 - #302:新增随仓库版本化的 GitHub 运营员工组织 `examples/github-ops/`:打开该目录即得到「问题调研 / PR 提交 / 合并把关」三个岗位。调研岗负责查重、复现并按模板建 issue;提交岗负责实现改动与提 PR;把关岗只在「存在非作者的批准、该 head 上必需检查全绿、无未解决对话、非 draft」时 squash 合并,合并前一刻重读 head SHA,禁用管理员绕过与 dismiss 他人的变更请求。三者均通过 `Bash` 调用 `gh`,不使用 MCP(内置宿主会以 `qoder.mcp_binding_unsupported` 拒绝员工 MCP 绑定)。注意:岗位包里的 `policy.network` / `policy.filesystem` / `policy.mode` 目前只被记录、不被执行,真正生效的是 `permissions.json` 的工具白名单,因此该员工在 GitHub 上的能力边界由所用凭据决定——建议用独立机器身份的细粒度 PAT,且不要授予 Administration。随附测试守护:声明与包一致(含 digest)、内置引擎能 `org apply`、控制面能打开并供出岗位卡。 diff --git a/apps/mobile-web/app.css b/apps/mobile-web/app.css new file mode 100644 index 00000000..75b931fa --- /dev/null +++ b/apps/mobile-web/app.css @@ -0,0 +1,213 @@ +:root { + color-scheme: dark; + --bg: #12141b; + --bg-elev: #1b1d26; + --ink: #f3f4f8; + --muted: #b0b5c4; + --line: #323644; + --accent: #7aa2ff; + --accent-ink: #101116; + --danger: #f0a8a8; + --radius: 16px; + --pad: 16px; + --tab: 64px; + font-family: Inter, "PingFang SC", "Noto Sans SC", system-ui, sans-serif; +} + +* { box-sizing: border-box; } +html, body { margin: 0; min-height: 100%; background: var(--bg); color: var(--ink); } +body { + min-height: 100dvh; + padding: + max(12px, env(safe-area-inset-top)) + max(var(--pad), env(safe-area-inset-right)) + calc(var(--tab) + env(safe-area-inset-bottom)) + max(var(--pad), env(safe-area-inset-left)); + overscroll-behavior: contain; + touch-action: manipulation; +} + +.skip { + position: absolute; + left: -999px; + top: 8px; +} +.skip:focus { + left: 12px; + z-index: 20; + padding: 8px 12px; + background: var(--accent); + color: var(--accent-ink); + border-radius: 8px; +} + +.top { margin-bottom: 18px; } +.brand { display: flex; gap: 12px; align-items: center; } +.mark { width: 36px; height: 36px; flex: none; } +.eyebrow { + margin: 0; + color: var(--accent); + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; +} +h1 { + margin: 2px 0 0; + font-size: 22px; + line-height: 1.25; + letter-spacing: -0.02em; +} +.lead, #command-status { color: var(--muted); line-height: 1.5; font-size: 15px; } + +.panel[hidden] { display: none; } +.panel.is-active { display: block; } + +.roles { + list-style: none; + margin: 16px 0 0; + padding: 0; + display: grid; + gap: 10px; +} +.role { + width: 100%; + text-align: left; + min-height: 72px; + padding: 14px 16px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--bg-elev); + color: inherit; + font: inherit; + display: grid; + gap: 4px; +} +.role:focus-visible, .button:focus-visible, .tab:focus-visible, input:focus-visible, textarea:focus-visible, button:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} +.role strong { font-size: 16px; } +.role span { color: var(--muted); font-size: 13px; } +.role:active { transform: scale(0.99); } + +.detail { + margin-top: 14px; + padding: 16px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--bg-elev); + display: grid; + gap: 10px; +} +.detail pre { + white-space: pre-wrap; + margin: 0; + color: var(--muted); + font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; +} +.meta { display: flex; gap: 8px; flex-wrap: wrap; } +.chip { + border: 1px solid var(--line); + border-radius: 999px; + padding: 4px 10px; + font-size: 12px; + color: var(--muted); +} + +.stack { display: grid; gap: 10px; margin: 20px 0; } +label { font-size: 14px; } +input, textarea, button, .button { + width: 100%; + font: inherit; + color: var(--ink); + border: 1px solid var(--line); + border-radius: 12px; + background: var(--bg-elev); +} +input, textarea { padding: 14px; } +input { letter-spacing: 0.35em; font-size: 22px; text-align: center; } +button, .button { + min-height: 48px; + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--accent); + color: var(--accent-ink); + border: 0; + font-weight: 650; + text-decoration: none; + cursor: pointer; +} +button:disabled { opacity: 0.55; cursor: not-allowed; } +.button.ghost { + background: transparent; + color: var(--ink); + border: 1px solid var(--line); +} +#command-summary { + white-space: pre-wrap; + background: var(--bg-elev); + border: 1px solid var(--line); + border-radius: 12px; + padding: 14px; + line-height: 1.45; +} + +.settings { + list-style: none; + margin: 0 0 16px; + padding: 0; + border: 1px solid var(--line); + border-radius: var(--radius); + overflow: hidden; +} +.settings li { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 14px 16px; + min-height: 48px; + border-bottom: 1px solid var(--line); +} +.settings li:last-child { border-bottom: 0; } +.settings span { color: var(--muted); } +.settings strong { font-weight: 600; text-align: right; } + +.tabs { + position: fixed; + left: 0; + right: 0; + bottom: 0; + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 4px; + padding: 6px 8px calc(6px + env(safe-area-inset-bottom)); + background: rgb(18 20 27 / 94%); + border-top: 1px solid var(--line); + backdrop-filter: blur(12px); +} +.tab { + min-height: 48px; + border: 0; + background: transparent; + color: var(--muted); + display: grid; + justify-items: center; + gap: 2px; + font-size: 11px; + font-weight: 600; + width: auto; + border-radius: 12px; +} +.tab svg { width: 22px; height: 22px; fill: currentColor; } +.tab.is-active { color: var(--ink); background: #262936; } + +.error { color: var(--danger); } + +@media (prefers-reduced-motion: reduce) { + .role:active { transform: none; } +} + +@media (min-width: 720px) { + main, .top { max-width: 42rem; margin-left: auto; margin-right: auto; } +} diff --git a/apps/mobile-web/app.mjs b/apps/mobile-web/app.mjs new file mode 100644 index 00000000..5407f7bd --- /dev/null +++ b/apps/mobile-web/app.mjs @@ -0,0 +1,193 @@ +const workspaceName = document.getElementById("workspace-name"); +const orgLead = document.getElementById("org-lead"); +const roleList = document.getElementById("role-list"); +const roleDetail = document.getElementById("role-detail"); +const pairForm = document.getElementById("pair-form"); +const commandForm = document.getElementById("command-form"); +const commandStatus = document.getElementById("command-status"); +const commandSummary = document.getElementById("command-summary"); +const storageKey = "roleweave-phone-link-v1"; + +let socket = null; +let deviceToken = localStorage.getItem(storageKey); +let selectedRoleId = null; + +function setCommandStatus(text) { + commandStatus.textContent = text; +} + +function showSummary(text) { + if (!text) { + commandSummary.hidden = true; + commandSummary.textContent = ""; + return; + } + commandSummary.hidden = false; + commandSummary.textContent = text; +} + +function formatBudget(role) { + const tokens = role.budget?.perTask?.tokens; + return typeof tokens === "number" ? `每任务 ${tokens.toLocaleString("zh-CN")} tokens` : "预算未声明"; +} + +function renderDetail(role, names) { + const reports = role.reportTo ? `汇报给 ${names.get(role.reportTo) ?? role.reportTo}` : "组织负责人"; + roleDetail.hidden = false; + roleDetail.innerHTML = ` +
${escapeHtml(role.description)}
+ +${escapeHtml(role.skillExcerpt || "这份岗位还没有说明书摘录。")}
+
+ `;
+ document.getElementById("talk")?.addEventListener("click", () => selectTab("command"));
+}
+
+function escapeHtml(value) {
+ return String(value)
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """);
+}
+
+function renderRoles(snapshot) {
+ workspaceName.textContent = snapshot.name;
+ orgLead.textContent = snapshot.description || "当前是示例组织的只读预览。选一个岗位,看说明书和预算。";
+ const names = new Map(snapshot.roles.map((role) => [role.id, role.name]));
+ roleList.replaceChildren();
+ for (const role of snapshot.roles) {
+ const item = document.createElement("li");
+ const button = document.createElement("button");
+ button.type = "button";
+ button.className = "role";
+ button.innerHTML = `${escapeHtml(role.name)}${escapeHtml(role.description)}`;
+ button.addEventListener("click", () => {
+ selectedRoleId = role.id;
+ renderDetail(role, names);
+ roleDetail.scrollIntoView({ block: "nearest" });
+ });
+ item.append(button);
+ roleList.append(item);
+ }
+ const initial = snapshot.roles.find((role) => role.id === selectedRoleId) ?? snapshot.roles[0];
+ if (initial) renderDetail(initial, names);
+}
+
+async function loadWorkspace() {
+ try {
+ const response = await fetch("/api/mobile/workspace", { headers: { accept: "application/json" } });
+ if (!response.ok) throw new Error("workspace unavailable");
+ renderRoles(await response.json());
+ } catch {
+ orgLead.textContent = "组织预览暂时读不到。桌面工作台仍可用。";
+ orgLead.classList.add("error");
+ }
+}
+
+function selectTab(id) {
+ for (const panel of document.querySelectorAll(".panel")) {
+ const active = panel.dataset.panel === id;
+ panel.classList.toggle("is-active", active);
+ panel.hidden = !active;
+ }
+ for (const tab of document.querySelectorAll(".tab")) {
+ const active = tab.dataset.tab === id;
+ tab.classList.toggle("is-active", active);
+ if (active) tab.setAttribute("aria-current", "page");
+ else tab.removeAttribute("aria-current");
+ }
+}
+
+function connect() {
+ if (!deviceToken) return;
+ socket?.close();
+ const url = `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/phone-link/phone`;
+ socket = new WebSocket(url);
+ socket.addEventListener("open", () => {
+ socket.send(JSON.stringify({ v: 1, type: "phone.hello", deviceToken }));
+ });
+ socket.addEventListener("message", (event) => {
+ let message;
+ try {
+ message = JSON.parse(String(event.data));
+ } catch {
+ return;
+ }
+ if (message.type === "phone.accepted") {
+ pairForm.hidden = true;
+ commandForm.hidden = false;
+ setCommandStatus("已连上电脑,可以发指令。");
+ return;
+ }
+ if (message.type === "command.status") {
+ const labels = {
+ accepted: "电脑已接到",
+ running: "员工正在处理",
+ completed: "完成",
+ failed: "失败",
+ busy: "该员工正在忙",
+ needs_approval: "请在电脑上确认",
+ };
+ setCommandStatus(labels[message.state] ?? message.state);
+ if (message.summary) showSummary(message.summary);
+ return;
+ }
+ if (message.type === "error") {
+ if (message.code === "unauthorized" || message.code === "revoked") {
+ localStorage.removeItem(storageKey);
+ deviceToken = null;
+ pairForm.hidden = false;
+ commandForm.hidden = true;
+ }
+ setCommandStatus(message.message ?? "出错了");
+ }
+ });
+ socket.addEventListener("close", () => {
+ if (deviceToken) setCommandStatus("连接断开,正在重试…");
+ });
+}
+
+pairForm.addEventListener("submit", async (event) => {
+ event.preventDefault();
+ const code = document.getElementById("code").value.trim();
+ setCommandStatus("正在配对…");
+ const response = await fetch("/phone-link/v1/pair", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ code }),
+ });
+ const body = await response.json();
+ if (!response.ok || !body.deviceToken) {
+ setCommandStatus(body.message ?? "配对失败");
+ return;
+ }
+ deviceToken = body.deviceToken;
+ localStorage.setItem(storageKey, deviceToken);
+ connect();
+});
+
+commandForm.addEventListener("submit", (event) => {
+ event.preventDefault();
+ const text = document.getElementById("text").value.trim();
+ if (!text || !socket || socket.readyState !== WebSocket.OPEN) {
+ setCommandStatus("还没连上电脑");
+ return;
+ }
+ showSummary("");
+ const commandId = crypto.randomUUID();
+ socket.send(JSON.stringify({ v: 1, type: "command.submit", commandId, text }));
+ setCommandStatus("已发出,等电脑受理…");
+});
+
+document.querySelector(".tabs").addEventListener("click", (event) => {
+ const tab = event.target.closest("[data-tab]");
+ if (tab) selectTab(tab.dataset.tab);
+});
+
+if (deviceToken) connect();
+void loadWorkspace();
diff --git a/apps/mobile-web/index.html b/apps/mobile-web/index.html
new file mode 100644
index 00000000..f5724569
--- /dev/null
+++ b/apps/mobile-web/index.html
@@ -0,0 +1,87 @@
+
+
+
+
+
+
+
+
+ RoleWeave
+正在读取组织…
+电脑上的 RoleWeave 必须已经开着。配对一次后,这里发的字会立刻交给绑定员工。
+ + +尚未配对。
+ +招聘、改组织、跑回合仍在电脑上的 RoleWeave 桌面应用里完成。手机壳只负责看组织和发一条指令。
+