From 4c9f139d7243a9520a58b6f780e2866f4593cc43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B1=E5=90=AF=E9=BA=92?= <61586210+bingqldx@users.noreply.github.com> Date: Thu, 7 May 2026 22:24:05 +0800 Subject: [PATCH 01/30] docs: add adaptive shell design spec --- .gitignore | 1 + ...05-07-codexmobile-adaptive-shell-design.md | 169 ++++++++++++++++++ 2 files changed, 170 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-07-codexmobile-adaptive-shell-design.md diff --git a/.gitignore b/.gitignore index f1c2306..050d4cb 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ test-results/ # Local runtime state, uploads, generated files, pairing tokens, logs .codexmobile/ +.superpowers/ .playwright-mcp/ .tmp* .tmp*/ diff --git a/docs/superpowers/specs/2026-05-07-codexmobile-adaptive-shell-design.md b/docs/superpowers/specs/2026-05-07-codexmobile-adaptive-shell-design.md new file mode 100644 index 0000000..92ce103 --- /dev/null +++ b/docs/superpowers/specs/2026-05-07-codexmobile-adaptive-shell-design.md @@ -0,0 +1,169 @@ +# CodexMobile Adaptive Shell Design + +Date: 2026-05-07 +Status: Approved for implementation planning + +## Goal + +Build a v1 adaptive desktop shell for CodexMobile while preserving the existing iPhone-first PWA experience. The first release should remove the 430px desktop phone-frame constraint, make the left project/session drawer efficient on desktop, widen the chat/composer reading surface, and add desktop-friendly image/file input through paste and drag/drop. + +## Approved Direction + +The selected direction is "v1 adaptive shell" implemented with a CSS-first layout change plus small React interaction additions. This intentionally avoids a broad component split or a full three-pane desktop redesign in the first pass. + +## Scope + +In scope: + +- At desktop width `>=1024px`, use the full browser viewport instead of centering a `430px` mobile shell. +- Dock the left drawer by default on desktop. +- Keep the desktop drawer collapsible so the chat area can take the full width when needed. +- Keep mobile and narrow tablet behavior as the current overlay drawer. +- Increase desktop chat readability by widening the main content column and composer. +- Support `Ctrl+V` / `Cmd+V` image paste in the composer. +- Support dragging images and files into the chat/composer area. +- Reuse the existing `/api/uploads` endpoint and `attachments` state. + +Out of scope for this first pass: + +- A full three-pane layout with a persistent right details panel. +- A broad split of `App.jsx` into many files. +- Reworking the visual brand, theme palette, or message rendering model. +- New backend upload APIs. +- Rich clipboard HTML import. + +## Architecture + +The existing front-end component model remains in place. `TopBar`, `Drawer`, `ChatPane`, and `Composer` continue to live in `client/src/App.jsx`; `client/src/styles.css` owns the responsive shell behavior. + +Desktop layout is controlled through media queries and a small amount of shell state: + +- Mobile/narrow layout remains `grid-template-rows: auto 1fr auto`. +- Desktop layout becomes a two-column app shell when the drawer is docked: left drawer plus main chat stack. +- The main chat stack contains top bar, chat pane, and composer. +- Drawer collapse state changes the desktop grid columns without changing the mobile drawer behavior. + +Upload input normalization should be separated into a small pure helper so paste/drop extraction can be tested without rendering the full React app. + +## Component Design + +### App Shell + +`App` will keep the existing `drawerOpen` state for mobile overlay behavior and add a desktop-oriented drawer state such as `desktopDrawerCollapsed`. The shell class can expose the state through class names such as: + +- `app-shell` +- `drawer-active` +- `desktop-drawer-collapsed` + +CSS should decide which states matter at each viewport width. At mobile widths, `drawerOpen` continues to control the overlay drawer. At desktop widths, the drawer is docked unless collapsed. + +### TopBar + +On mobile, the menu button continues to open the drawer. + +On desktop, the same control becomes a drawer collapse/expand toggle. The rest of the top-bar actions remain in the existing menu model for v1. + +### Drawer + +On mobile, `Drawer` stays fixed, hidden by default, and opened over a backdrop. + +On desktop, `Drawer` is visually docked: + +- No backdrop. +- No slide-in transform for the open state. +- Width target: about `300px` to `340px`. +- Height follows the app viewport. +- Scrolling stays inside the drawer. + +When collapsed, the drawer should not block the chat. The collapse affordance must remain discoverable through the top-bar menu button or a narrow rail. + +### ChatPane + +Desktop `ChatPane` should use the available viewport and keep the reading column constrained for readability. The target desktop content max width is `960px` to `1100px`, wider than the current `820px` but not full-bleed text. + +Assistant messages remain a wide reading flow. User messages remain right-aligned bubbles. + +### Composer + +`Composer` gains paste and drag/drop handlers while reusing the current upload pathway. + +Paste behavior: + +- Inspect `clipboardData.items` / `clipboardData.files`. +- Extract only file items with MIME type `image/*`. +- If image files are found, prevent the browser's default image paste handling and call `onUploadFiles(files)`. +- If no image file is found, do not prevent default so normal text paste works. + +Drag/drop behavior: + +- When the drag payload includes files, show a lightweight drop overlay. +- Prevent the browser from navigating to dropped files. +- On drop, call `onUploadFiles(files)` with all dropped files. +- Hide the overlay on successful drop, cancel, or leaving the drop zone. + +## Data Flow + +The upload data flow stays unchanged: + +1. `Composer` receives pasted or dropped `File` objects. +2. `Composer` calls `onUploadFiles(files)`. +3. `App.handleUploadFiles` sets `uploading`. +4. Each file is posted to `/api/uploads` as `multipart/form-data`. +5. Successful uploads append `result.upload` to `attachments`. +6. The existing send flow includes `attachments` with the message. + +No backend changes are required for v1. + +## Error Handling + +Upload failures continue to use the existing activity-message error path in `handleUploadFiles`. + +Drag/drop should avoid destructive browser defaults. Dropping files onto the app should not navigate away from the current thread. Dropping non-file data should leave the app unchanged. + +Paste should be conservative. Plain text paste, file mention text, and slash/skill typing behavior must continue to work. + +## Testing + +Automated tests: + +- Add a unit test for extracting image files from paste events. +- Add a unit test that paste without image files is ignored. +- Add a unit test for extracting multiple files from a drop event. +- Keep existing composer shortcut tests passing. + +Verification commands: + +- Run the existing front-end/unit tests with the repository's Node test pattern. +- Run `npm run build`. + +Manual/browser checks: + +- Desktop width: app fills the viewport instead of showing a centered 430px shell. +- Desktop width: drawer is docked by default and can be collapsed. +- Desktop width: chat and composer widths feel usable for long messages and code blocks. +- Mobile width: drawer still behaves as an overlay and the composer remains usable. +- Paste an image into the composer and confirm it appears in the attachment tray. +- Drag an image or file into the chat/composer area and confirm it appears in the attachment tray. + +## Implementation Notes + +Primary files: + +- `client/src/App.jsx` for shell state, top-bar action wiring, and composer paste/drop handlers. +- `client/src/styles.css` for responsive desktop shell, docked drawer, wider chat column, and drop overlay styling. +- A small helper file under `client/src/` for paste/drop file extraction, with matching `.test.mjs`. + +Repository hygiene: + +- `.superpowers/` is ignored because visual companion mockups and browser selection state are local planning artifacts. +- Existing unrelated modifications, especially `server/codex-app-server.js`, are outside this UI pass and must not be reverted or included accidentally. + +## Acceptance Criteria + +- At `>=1024px`, CodexMobile uses a desktop-width adaptive shell rather than a phone-frame shell. +- At mobile widths, the current PWA drawer and composer behavior remain intact. +- The desktop drawer is docked by default and can be collapsed. +- Chat and composer are visibly wider and aligned as one main working column. +- Image paste uploads through the current attachment system. +- Drag/drop uploads images and files through the current attachment system. +- Build and relevant tests pass. From 3f14593c2b0807f840f506e2de40e8fd704953d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B1=E5=90=AF=E9=BA=92?= <61586210+bingqldx@users.noreply.github.com> Date: Thu, 7 May 2026 22:29:27 +0800 Subject: [PATCH 02/30] docs: add adaptive shell implementation plan --- .../2026-05-07-codexmobile-adaptive-shell.md | 788 ++++++++++++++++++ 1 file changed, 788 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-07-codexmobile-adaptive-shell.md diff --git a/docs/superpowers/plans/2026-05-07-codexmobile-adaptive-shell.md b/docs/superpowers/plans/2026-05-07-codexmobile-adaptive-shell.md new file mode 100644 index 0000000..48be90a --- /dev/null +++ b/docs/superpowers/plans/2026-05-07-codexmobile-adaptive-shell.md @@ -0,0 +1,788 @@ +# CodexMobile Adaptive Shell Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Turn CodexMobile's desktop view into a usable adaptive shell with a docked collapsible drawer, wider chat/composer column, and desktop paste/drag file upload. + +**Architecture:** Keep the existing `App.jsx` component structure and make the first pass CSS-first. Add one focused helper module for paste/drop file extraction so upload behavior can be tested without rendering the full React app. Reuse the existing `/api/uploads` and `attachments` flow. + +**Tech Stack:** React 18, Vite, Node.js built-in `node:test`, CSS media queries, existing CodexMobile upload API. + +--- + +## File Structure + +- Create `client/src/upload-inputs.js`: pure helpers for clipboard/drop file extraction and drag file detection. +- Create `client/src/upload-inputs.test.mjs`: unit tests for paste/drop extraction behavior. +- Modify `client/src/App.jsx`: import upload helpers, add composer paste/drop handlers, add desktop drawer collapse state, and wire the top menu button to mobile open vs desktop collapse behavior. +- Modify `client/src/styles.css`: replace the desktop phone-frame breakpoint with a `>=1024px` adaptive shell, dock/collapse the drawer, widen chat/composer, and style the drop overlay. + +Do not modify `server/codex-app-server.js`; it has unrelated local changes. + +--- + +### Task 1: Upload Input Extraction Helpers + +**Files:** +- Create: `client/src/upload-inputs.js` +- Create: `client/src/upload-inputs.test.mjs` + +- [ ] **Step 1: Write the failing tests** + +Create `client/src/upload-inputs.test.mjs` with: + +```js +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + dragEventHasFiles, + filesFromClipboardEvent, + filesFromDropEvent +} from './upload-inputs.js'; + +function fakeFile(name, type) { + return { name, type, size: 1234 }; +} + +function fakeClipboardItem(file) { + return { + kind: 'file', + type: file.type, + getAsFile: () => file + }; +} + +test('filesFromClipboardEvent extracts only pasted image files', () => { + const image = fakeFile('plot.png', 'image/png'); + const textFile = fakeFile('notes.txt', 'text/plain'); + const event = { + clipboardData: { + items: [ + fakeClipboardItem(image), + fakeClipboardItem(textFile), + { kind: 'string', type: 'text/plain', getAsFile: () => null } + ], + files: [] + } + }; + + assert.deepEqual(filesFromClipboardEvent(event), [image]); +}); + +test('filesFromClipboardEvent ignores plain text paste', () => { + const event = { + clipboardData: { + items: [{ kind: 'string', type: 'text/plain', getAsFile: () => null }], + files: [] + } + }; + + assert.deepEqual(filesFromClipboardEvent(event), []); +}); + +test('filesFromClipboardEvent falls back to clipboardData.files for images', () => { + const image = fakeFile('clipboard.jpg', 'image/jpeg'); + const event = { + clipboardData: { + items: [], + files: [image, fakeFile('document.pdf', 'application/pdf')] + } + }; + + assert.deepEqual(filesFromClipboardEvent(event), [image]); +}); + +test('filesFromDropEvent returns all dropped files', () => { + const image = fakeFile('screen.png', 'image/png'); + const sheet = fakeFile('table.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + const event = { + dataTransfer: { + files: [image, sheet] + } + }; + + assert.deepEqual(filesFromDropEvent(event), [image, sheet]); +}); + +test('dragEventHasFiles detects drag payloads that include files', () => { + assert.equal(dragEventHasFiles({ dataTransfer: { types: ['text/plain'] } }), false); + assert.equal(dragEventHasFiles({ dataTransfer: { types: ['Files'] } }), true); + assert.equal(dragEventHasFiles({ dataTransfer: { files: [fakeFile('a.png', 'image/png')] } }), true); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail because the module is missing** + +Run: + +```powershell +node --test client/src/upload-inputs.test.mjs +``` + +Expected: FAIL with an import/module-not-found error for `client/src/upload-inputs.js`. + +- [ ] **Step 3: Add the minimal helper implementation** + +Create `client/src/upload-inputs.js` with: + +```js +function asArray(value) { + return Array.from(value || []); +} + +function isImageFile(file) { + return Boolean(file && String(file.type || '').startsWith('image/')); +} + +export function filesFromClipboardEvent(event) { + const data = event?.clipboardData; + if (!data) { + return []; + } + + const itemFiles = asArray(data.items) + .filter((item) => item?.kind === 'file' && String(item.type || '').startsWith('image/')) + .map((item) => item.getAsFile?.()) + .filter(isImageFile); + + if (itemFiles.length) { + return itemFiles; + } + + return asArray(data.files).filter(isImageFile); +} + +export function filesFromDropEvent(event) { + return asArray(event?.dataTransfer?.files).filter(Boolean); +} + +export function dragEventHasFiles(event) { + const transfer = event?.dataTransfer; + if (!transfer) { + return false; + } + if (asArray(transfer.files).length > 0) { + return true; + } + return asArray(transfer.types).includes('Files'); +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: + +```powershell +node --test client/src/upload-inputs.test.mjs +``` + +Expected: PASS, 5 tests. + +- [ ] **Step 5: Commit the helper** + +Run: + +```powershell +git add client/src/upload-inputs.js client/src/upload-inputs.test.mjs +git commit -m "feat: add upload input extraction helpers" +``` + +--- + +### Task 2: Composer Paste And Drag/Drop Upload + +**Files:** +- Modify: `client/src/App.jsx` +- Test: `client/src/upload-inputs.test.mjs` + +- [ ] **Step 1: Run the focused helper tests before wiring React** + +Run: + +```powershell +node --test client/src/upload-inputs.test.mjs +``` + +Expected: PASS. This guards the helper behavior before UI wiring. + +- [ ] **Step 2: Import the helper functions** + +In `client/src/App.jsx`, add this import next to the other local imports: + +```js +import { + dragEventHasFiles, + filesFromClipboardEvent, + filesFromDropEvent +} from './upload-inputs.js'; +``` + +- [ ] **Step 3: Add drag state and handlers inside `Composer`** + +Inside `function Composer(...)`, after the existing `useState` calls for `openMenu`, `skillFilter`, `cursorPosition`, and `fileSearch`, add: + +```js + const [dragDepth, setDragDepth] = useState(0); + const dropActive = dragDepth > 0; +``` + +After `handleFiles(event, kind)`, add: + +```js + function uploadFiles(files) { + if (!files.length) { + return; + } + onUploadFiles(files); + setOpenMenu(null); + } + + function handlePaste(event) { + const files = filesFromClipboardEvent(event); + if (!files.length) { + return; + } + event.preventDefault(); + uploadFiles(files); + } + + function handleDragEnter(event) { + if (!dragEventHasFiles(event)) { + return; + } + event.preventDefault(); + setDragDepth((value) => value + 1); + } + + function handleDragOver(event) { + if (!dragEventHasFiles(event)) { + return; + } + event.preventDefault(); + event.dataTransfer.dropEffect = 'copy'; + } + + function handleDragLeave(event) { + if (!dragEventHasFiles(event)) { + return; + } + event.preventDefault(); + setDragDepth((value) => Math.max(0, value - 1)); + } + + function handleDrop(event) { + if (!dragEventHasFiles(event)) { + return; + } + event.preventDefault(); + setDragDepth(0); + uploadFiles(filesFromDropEvent(event)); + } +``` + +- [ ] **Step 4: Wire the form and textarea events** + +Change the `