Read CONTEXT.md first. This round audited why zh web lacks desktop-app features and shipped three mobile fixes in packages/web only — no new @zcode/ui patches. Released as v0.5.3.
- zh web is built from the newest PUBLIC upstream tag, v3.14.3
(
ZCODE_VENDORED_REV= 29628c9;zai-org/ZCodeorigin HEAD and tag list still stop at v3.14.3 as of 2026-10-02). The desktop app and the zcode.z.ai/remote/v4 controller run v3.14.4, built 2026-09-29 from commit10bbcea5(app.asarbuild-meta.json) which does not exist in the public repo. The app.asar has no sourcemaps (the.tsfiles inside are third-party node_modules); it is usable as a behavior reference only. Missing features are version gaps, not zh bugs:- Trust Build daily claim card = upstream
manualClaimPlan(banner, claim ticket dialog, share sheet, billing API/api/v1/zcode-plan/billing/preview, desktop host actionclaim_zcode_plan). Zero occurrences in v3.14.3 source or the zh dist. Decision: wait for the v3.14.4 vendor sync; do not port from the minified bundle. - Message action row (copy/like/dislike/fork/time) EXISTS in 3.14.3 but is
hover-only (
group-hover/assistant-turn:opacity-100); 3.14.4 addscompactForRemoteControlwhich keeps it visible in the remote surface. - 3.14.4 also ships a
WebRemoteControlMobileShell(phone home/chat pages,webRemoteControl.mobileShell.*i18n, history back support); the interim drawer glue below is meant to be dropped when the vendor sync lands.
- Trust Build daily claim card = upstream
packages/web/src/mobileShell.ts: the drawer closes on tapping navigating sidebar entries ([data-testid^="task-item-"],conversation-new-task,automations-open,plugin-store-sidebar-open) via a capture-phase click listener that dispatches the existingzh:close-sidebarevent; taps on inner row buttons (pin/archive/menu triggers) are ignored. Workspace rows deliberately do NOT close: their tap expands the workspace task list, which the user still needs on screen.packages/web/src/mobileShell.css: on(hover: none) and (pointer: coarse)the hover-revealed action rows (copy/like/fork/time under assistant turns, edit under user rows) are always visible, mirroring 3.14.4's compactForRemoteControl behavior on touch until the sync lands.packages/web/src/mobileShell.css: while a section main is mounted (#automations-main-toast-anchoror[data-testid="plugin-store-root"]), the floating top overlay becomes a solid full-width navbar and the section main gets 56px (h-14) padding-top. Root cause: the section breadcrumb row renders only whenisDesktop(AutomationsMainBreadcrumbFrame), and on phones the shell main area is full width because the sidebar is a drawer. On the chat view neither marker exists and the overlay keeps the upstream floating style. CSS gotchas verified the hard way: nested:has()is invalid and silently dropped (single-level:hasonly), and the section scopes areScopedErrorBoundary, not keep-alive, so they really unmount on navigation.packages/web/src/main.tsx: a failed web bootstrap (WebSocket connect) now auto-reloads after 1s within a budget of 3 reloads per 60s, tracked in sessionStorage (zh:web-bootstrap-reloads); past the budget the error card with manual Retry stays, and a successful boot clears the counter. Cause: mobile browsers park background tabs and drop the WebSocket; when Chrome restores the tab the boot connect races the network stack coming back up.
bunx oxlintclean on all three files;tsc --noEmit --noResolveshows no errors in the added code. Repo suitebun test packages/services/test/: 14 pass / 0 fail (clean env).- Runtime matrix in a real browser (in-app Chrome, 390x844 viewport) against the bun-built bundle: drawer closes on task row and nav entries, does not close on inner row buttons / workspace row / plain areas, works repeatedly; after close the mirror clears and the scrim hides. Guard: 13/13 assertions including a real page reload fired by the guard itself; budget exhaustion, stale-window reset, and clear-on-success all verified.
- CSS fixture: 9/9 across section-open (navbar + offsets) and chat-view (floating overlay restored) states.
- Not yet verified on a physical phone (needs the deploy of this release): park zh.coderbuzz.dev and return — it should reload itself without the error card; open Automations / Plugin Marketplace — title sits below the navbar and scrolling stays clear of the overlay icons.
Status for the production bug found 2026-10-01 ~22:11 WIB (Z.ai browser login in zh web mode pends forever). Read CONTEXT.md first. The previous handoff (zh web, session 2026-09-29, plus the pending v3.14.4 sync runbook) follows below, unchanged and still current.
Everything is done except the real-account login step (needs the owner's Z.ai credentials in a browser):
- Merged: PR #34 squash-merged to main as f7cc6c49
(
fix: keep server-provided OAuth callback URL in zh web login (#34)); branch deleted. - Mac build:
sh build-all.shwith everyZCODE_*desktop var stripped (14 leaked vars found and unset) — all packages OK.bun test packages/services/test/: 14 pass / 0 fail, including the 4 tests inoauthPollingAuthorizeUrl.test.ts. - Release: PR #35 bumped the root version to 0.5.2; tag
v0.5.2pushed at b52b88b8; release workflow completed and published all assets, includingzheadless-web-v0.5.2.tar.gz(SHA256SUMS-verified before deploy). - Deploy: the deployment VM is
indra@34.168.150.46(host instance-20260813-081715);zh-web.serviceis a USER service (systemctl --user), port 8787, install root~/.local/share/zheadless. Redeployed by stopping the service, replacingweb/+server/from the released web asset, and restarting. Verified: service active;/200;/apiand/ws401 without token; token via?token=passes the gate (Bearer header is not how the server validates — seehasValidLiteTokeninpackages/server/src/http.ts). The deployedentry-http.jshas 0 hits for theredirect_uri/redirectrewrites and keeps the intentionalzcode://oauth/callback(3x) +/app/oauth/login(1x) deep-link literals.entry-http.js.bak-oauthcb-20261001is deleted (went with the replacedserver/dir). Note: the unit Description still says v0.5.1 (cosmetic), and the VM's agent bundledist/zcode.cjsis still v0.5.1 — harmless for this fix, which lives in the server bundle. - Real-browser E2E on the Mac (isolated:
ZCODE_DATA_BASE_DIR=/tmp/zh-e2e-oauth,bin/zh web --host 127.0.0.1 --port 4193 --no-open): clicking Connect to Z.ai starts polling and the browser tab openshttps://chat.z.ai/auth?...redirect_uri=https%3A%2F%2Fzcode.z.ai%2Fapi%2Fv1%2Foauth%2Fcli%2Fcallback%2Fzai&state=...— the official callback, nothing rewritten, state intact. COMPLETED 2026-10-02 ~08:26 WIB with the owner's real Z.ai account: flow started 08:26:31,OAuth polling flow completed { provider: 'zai' }logged at 08:26:36 (5.6 s — the old build pended forever here), credentials.json in the isolated store gained theoauth:zaisession + active provider, and the UI left the welcome gate for the onboarding wizard in the same tab without a manual reload. Also clean-room verified with OrbStack (node:22-slim): the releasedzheadless-web-v0.5.2.tar.gzboots alone, serves the UI, enforces the 401 posture, and passes the authorize URL through byte-identical — the asset is self-contained. (Container note: accessing a token-protected server without?token=shows "Web bootstrap failed / WebSocket connection failed" by design; the lite token comes from the query param.) The E2E server with its isolated data dir (/tmp/zh-e2e-oauth) was left running on port 4193 for the owner to try.
Work branch fix/web-oauth-callback (base: main @ b209d84), two commits:
fix: keep server-provided OAuth callback URL in zh web login(ea6a204): the fix plus the regression test.docs: record OAuth callback fix status and VM handoff: this section.
Root cause: startOAuthWithPolling in
packages/services/src/oauth/oauthService.ts unconditionally rewrote the
authorize URL returned by the zcode.z.ai cli/init endpoint: redirect_uri
(Z.AI) and redirect (BigModel) were set to
<origin>/app/oauth/login?redirect=zcode://oauth/callback&app_version=....
The official CLI callback (https://zcode.z.ai/api/v1/oauth/cli/callback/zai)
is the only thing that marks the server-side flow ready for polling; browsers
cannot follow zcode://, so pollPendingOAuth never resolved.
Fix: the rewrite is removed; the server-provided authorize URL passes through
byte-identical. The desktop deep-link contract stays intact
(buildDesktopOAuthRedirectUriFromEnv, the legacy non-polling startOAuth
flow, handleCallback). No desktop-vs-web gate was added: zh ships no desktop
client that registers zcode:// (the web platform stub's onOAuthCallback is
a no-op), and host-side polling completes regardless of what the browser lands
on, so the deep-link path is dead code here and the unconditional default is
the pass-through.
Verified on the build VM (2026-10-02):
bun test packages/services/test/: 14 pass / 0 fail, including the newpackages/services/test/oauthPollingAuthorizeUrl.test.ts: Z.AI and BigModel authorize URLs pass through unchanged (no/app/oauth/login, nozcode://), state preserved; polling resolves to a logged-in session via a stubbed init/poll backend; the legacy provider config still builds the desktop relay URL (/app/oauth/login+zcode://oauth/callback).bunx oxlinton both changed files: 0 warnings.tsc --noEmit -p packages/services/tsconfig.json: clean.bun packages/server/scripts/build.mjsrebuiltdist/entry-http.js: thesearchParams.set("redirect_uri"|"redirect", ...)rewrites are gone from the bundle; the remainingzcode://oauth/callback(3x) and/app/oauth/login(1x) literals are the intentional desktop deep-link contract and provider config fallbacks.- Live check against the rebuilt bundle serving on 127.0.0.1:4190:
startOAuthWithPolling("zai")over WebSocket RPC returned the real zcode.z.ai init response unchanged:redirect_uri=https://zcode.z.ai/api/v1/oauth/cli/callback/zai, state intact, nothing rewritten. Matches the hot patch verified on the deployment VM the same evening. - Not verified (needs a real Z.ai account + browser): completing an actual login end to end and watching the UI flip to logged in without reload. The polling completion path itself is covered by the stubbed-poll test above.
done.sh build-all.shon the MacOpen the PR fordone (PR #34).fix/web-oauth-callback, squash-mergeReal-browser E2Edone — full login with the real account completed 2026-10-02 ~08:26 WIB; polling resolved in 5.6 s and the UI flipped to logged in without a reload.Deploy + deletedone.entry-http.js.bak-oauthcb-20261001Bump the root versiondone (v0.5.2, PR #35).
The 2026-10-01 session died during cleanup: pkill -f entry-http.js matched
its own shell command line (killing the turn) and also terminated the
production zh-web.service process. systemd restarted production within
about 2 minutes (later verified healthy, 401-without-token posture intact),
but the session never recovered, likely compounded by memory pressure
(964 MB RAM, swap in use). Rules going forward on this VM: stop services via
systemctl, never pkill -f with a pattern that can match the running
command, and avoid heavy builds while zh-web.service runs.
Written from the 2026-09-29 session that had to stop mid-verification (browser automation calls kept getting cancelled on this machine; the work continues on another machine). Read CONTEXT.md first, then this file. Everything below is verified fact unless labeled otherwise.
Branch feat/zh-web (based on main at 40e5ab9e, the vendor merge) holds all
implementation commits:
- 0eb02ec1 feat(cli): add the zh web command
- ea715d92 feat(web): zh branding and the Bun web build
- baadef3b feat(server): bundle the HTTP entry with Bun and stage its runtime
- 3065514e build(ci): ship the zh web release asset
On main (already pushed): scripts/sync-vendor.sh tracks
rpc client services server web; the vendor branch was regenerated at the
same baseline (zcode v3.14.3 = 29628c9, vendor commit 6745ff8) and merged as
40e5ab9e "Merge vendor/upstream: zcode v3.14.3 web mode packages". 22 packages
now live on the vendor branch. main and vendor/upstream are pushed.
- Bun.build fully replaces Vite for packages/web. Verified empirically on
bun 1.4.2 against the real app (not a toy): HTML entry,
defineincludingimport.meta.env.*keys,alias, and JSX all work. Reference comparison at the same rev: vite 144 MB / 6137 files / 2246 sourcemaps vs bun 60-63 MB / ~3956 files / 0 maps. - Three Bun.build gaps, all handled in
packages/web/scripts/build.mjs:new Worker(new URL(...), import.meta.url)is not transformed: two ui sites (diffs worker, workspace file-search worker) are text-rewritten to/workers/*.jspublic paths and built as separate worker bundles. The diffs worker must bundle@pierre/diffs/worker/worker.jsdirectly as the entry or its side-effect-only import tree-shakes to 0 bytes (the upstream vite config disables treeshake for workers for the same reason).?urlimports (pdf.js worker .mjs, two .wasm) need an onResolve+onLoad plugin with a namespace and thefileloader.new URL("../../../public/icon_512@2x.png", import.meta.url)in UpdateStatusDialog is rewritten to/icon_512@2x.png(icon copied into dist; dialog is unreachable in web mode anyway).
- Tailwind v4: the
@tailwindcss/viteplugin is replaced by a pinnedbunx @tailwindcss/cli@4.2.2pre-pass overpackages/ui/src/styles.css(same compiler; 499 KB CSS in ~0.2 s; all@import/@pluginresolved). KaTeX fonts are staged next to the generated CSS so Bun.build inlines them as data URLs (that is why the bun dist is a few MB larger than vite's). - index.html script src must be relative (
./src/main.tsx): Bun.build resolves root-absolute paths against cwd. - Server build: tsup/esbuild are NOT used (repo policy: esbuild removed).
packages/server/scripts/build.mjs(Bun.build) emitsdist/entry-http.jsonly; the upstreamdist/remotesingle-file bundle is intentionally not built. Externals: ssh2, node-pty, undici, axios, form-data, combined-stream, proxy-from-env, follow-redirects, node-forge, yaml, yazl, yauzl. - The server runs under Node >= 22, not bun (node-pty native addon) -- owner decision, keep it. Agent child stays under bun.
- The web server resolves its node binary from
ZH_WEB_NODEenv, then.zh-runtimeZH_RUNTIME_NODE, then PATH, then errors with a clear message (version-checked >= 22). - node-pty prebuilds: npm node-pty 1.1.0 ships only darwin/win32
prebuilds; linux ones come from the four
@lydell/node-pty-*@1.2.0-beta.10packages. bun install skips os/cpu-gated packages, soscripts/stage-server-runtime.mjsfetches missing ones from the registry (Bun fetch of registry.npmjs.org metadata + tarball, then tar -xzf). Root devDependencies were tried and do NOT help; do not add them back. - packages/web is excluded from the zh workspace (
"workspaces": ["packages/*", "!packages/web"]) because@zcode/uiis deliberately not vendored andbun installwould fail. Web builds only in CI, inside an upstream monorepo checkout. - One release asset
zheadless-web-<tag>.tar.gzcontainingweb/+server/, extracted byinstall-remote.sh --webinto the install root.zh webfinds<root>/server/entry-http.js+<root>/web/by walking up from the real entry anchor (bundle path or execPath for the binary), or the source layoutpackages/server/dist+packages/web/dist. - Branding scope (owner decision): tab title, favicon, boot logo, and the four document.title strings in main.tsx are zh. Strings inside @zcode/ui stay upstream; document as a limitation, do not patch @zcode/ui.
zh web --helpshows the GLOBAL help (run.ts intercepts --help before command dispatch). Do not add per-command help handling.
All with every ZCODE_* var stripped (desktop app pollution; use the wrapper
recipe in CONTEXT.md gotchas or: for v in $(env | sed -n 's/^\(ZCODE_[^=]*\)=.*/\1/p'); do unset "$v"; done).
- Clean-room build PASS:
rm -rf node_modules packages/*/distthenbun install && sh build-all.sh-> 15/15 OK (13 tsc packages, tui, server). bun run typecheckin packages/cli PASS (one fix applied: the duplicatehelp === truenarrowing in run.ts).- CLI bundle built:
packages/cli/dist/zcode.cjs24.8 MB. - Web dist built via CI-overlay simulation (rsync packages/web into the
../zcode clone checkout at 29628c9,
pnpm installonce because rsync --delete removes pnpm's per-package node_modules links, thenZCODE_BUILD_VERSION=0.4.0-local bun packages/web/scripts/build.mjs): 60 MB, version string verified inside a chunk. The dist lives atpackages/web/distin this repo now (gitignored) sozh websource-layout resolution works locally. - Server runtime staged:
/tmp/zh-server-stage(88 MB) with entry-http.js, notices, and all four platform node-pty prebuilds (darwin-arm64/x64, linux-arm64/x64). - zh web ran end-to-end from the bundle:
bun packages/cli/dist/zcode.cjs web --port 4180 --no-openfrom/tmp/zh-web-ws-> banner printed, agent child =bun .../dist/zcode.cjs app-server --stdio, server log showedzcode-server:http http://127.0.0.1:4180, and the browser loaded the UI: tab title "zh - Web + Server", sidebar rendered with real workspace history (zh-web-ws project visible). - Earlier standalone smoke test (same bun dist, static file server, no backend): title "ZCode - Web" set at runtime and the correct "Web bootstrap failed / WebSocket connection failed" screen rendered -- proves the bundle executes without a dev server.
- Browser chat round-trip against
zh web(port 4180 recipe above): send "balas persis satu kata: SIAP", expect the exact reply. Close the command palette (Escape) first; the composer is a textbox below. NOTE: on the 2026-09-29 machine the browser tool cancelled ontab.cua.keypressrepeatedly; use playwright locators or a fresh machine. - PTY terminal test: open the terminal panel, run
echo hiandgit branch --show-current, verify output. - Auth checks (server running with
--host 0.0.0.0):curl -s -o /dev/null -w "%{http_code}" http://<lan-ip>:<port>/from OUTSIDE the machine (or another container) must be 401 without token;http://<lan-ip>:<port>/?token=<token from banner>must load (200 + app boots). Loopback stays tokenless by default.
- Container test (OrbStack, debian:bookworm-slim): install bun + node,
then the release layout by hand (bundle tarball content + staged web/server
tree, or run install-remote.sh with --asset-dir pointing at locally built
assets), start
zh web --host 0.0.0.0 --port 4180, and repeat the chat round-trip from the host against the container IP with a token.scripts/stage-server-runtime.mjsoutput +packages/cli/dist/zcode.cjsbin/zh+packages/web/distis exactly the release layout (<root>/bin/zh,<root>/dist/zcode.cjs,<root>/web/,<root>/server/).
- Docs: close CONTEXT.md open thread #1 with the final decisions and this evidence; README section for web mode (usage, Node >= 22 requirement, limitations: file picker, remote workspace wizard SSH/WSL/Docker, embedded browser, phone-remote are upstream stubs; @zcode/ui strings stay upstream; web dist is built in CI, not locally). Follow the antislop rules.
- PR: push feat/zh-web (done), open PR, squash-merge, delete branch. Bump root version (0.5.0) in the same round if releasing immediately; CI already builds the web asset on the next tag.
- Before merging, re-check
.github/workflows/release.ymljob wiring only by reading (build-web -> packages needs web-dist artifact; ZCODE_VENDORED_REV env must match the vendor baseline; update it when vendor moves).
The v3.14.4 sync plan that lived here assumed the vendor/upstream merge flow. That flow
is gone (see CONTEXT.md "Upstream sync"); the baseline has since moved to v3.15.1 by
bumping ZCODE_VENDORED_REV directly.
- Clean-env wrapper from this session:
/tmp/zh-clean-env.sh(recreate from the snippet above; it just unsets every ZCODE_* var and execs). - Run zh web locally (bundle path):
cd /tmp/zh-web-ws && bun .../packages/cli/dist/zcode.cjs web --port 4180 --no-open - Rebuild web dist locally (needs the ../zcode clone at 29628c9; note the
clone was re-created 2026-10-01, so run
pnpm installthere first): rsync overlay per step 4 above. - Rebuild server bundle + runtime:
bun packages/server/scripts/build.mjsthenbun scripts/stage-server-runtime.mjs /tmp/zh-server-stage.