Build a Firefox Context Bridge

Build a Firefox extension that queues local context for NextWork prompts.

Introduction

30 Second Summary

Useful project context rarely lives in one place. Moving notes or source files into an AI conversation can turn careful preparation into repetitive copying.

In this project, you will build a local-first Firefox sidebar called the NextWork Context Bridge. The WebExtension turns structured fields, project folders, ZIP archives, reusable skills, and selected images into a reviewable prompt package.

What You'll Build

You'll watch the sidebar unpack a project bundle, apply a reusable skill, and prepare a labelled visual sheet beside the exact prompt you review for NextWork.

By the end of this project, you'll have:

  • A live structured prompt preview where Goal, Context, Output, Boundaries, and a selected skill appear in a stable order.
  • A project bundle reader that preserves folder paths, unpacks ZIP archives locally, and shows the resulting source tree before any handoff.
  • A multimodal handoff that pairs reviewed text chunks with a compressed, labelled visual sheet you can attach manually to NextWork chat.
  • Secret Mission: Pressure-test the bridge with two sources that disagree. Require an evidence-aware answer that names uncertainty and recommends a next verification step.

Are there any prerequisites?

You'll need a Mac with Firefox, an active NextWork account, and permission to use every local file you select. This project assumes Node.js 22.22.3 plus Visual Studio Code are already installed.

Before We Start

Lock in the bridge's purpose before the hands-on work begins. Selected file content remains in sidebar memory until the sidebar closes or reloads, while content inserted into or sent through NextWork is no longer local.

Set Up the Firefox Extension Shell

A standard web app does not provide extension manifests. It also lacks the side-panel entrypoint this bridge needs.

WXT creates the Firefox extension shell. It generates the browser configuration from your project files.

The pinned shell uses React for its interface. TypeScript keeps the component wiring explicit.

Vite drives the development build. Tailwind CSS supplies the styling pipeline.

In this step, get ready to:
  • Create the Context Bridge folder and verify Node.js plus npm.
  • Add the pinned extension configuration and side-panel entrypoint files.
  • Install the project dependencies and launch the sidebar in Firefox.
Create the project folder and verify Node.js

The project needs one predictable home for its configuration and source files. You will create that home on your Desktop before opening it in Visual Studio Code.

  • Press Cmd+Space to open Spotlight.
  • Type Finder.
  • Press Enter.
  • Select Desktop in the Finder sidebar.
  • Open the File menu.
  • Select New Folder.
  • Name the folder nextwork-context-bridge.

You should see the nextwork-context-bridge folder on your Desktop.

  • Press Cmd+Space to open Spotlight again.
  • Type Visual Studio Code.
  • Press Enter.
  • Click File in the Visual Studio Code menu bar.
  • Select Open Folder.
  • Select the nextwork-context-bridge folder on your Desktop.
  • Click Open.

The Explorer sidebar should show an empty NEXTWORK-CONTEXT-BRIDGE folder.

Node.js runs the local extension toolchain. The version check confirms that Node.js and npm are available inside Visual Studio Code.

  • Click Terminal in the Visual Studio Code menu bar.
  • Select New Terminal.
  • Verify Node.js and npm by running this command:
node -v && npm -v

What does this command check?

  • The first check prints your installed Node.js version.
  • The second check prints your installed npm version.
  • Both values prove that the project-local package tools are ready.

✔️ I see the required Node.js version

Your Node.js output should show v22.22.3. A second version number confirms that npm is available.

Your local JavaScript toolchain is ready for the pinned extension packages.

ⓧ I see an older Node.js version

The project uses Node.js 22.22.3. Update Node.js before installing the project dependencies.

  • Open the official Node.js release page for version 22.22.3.
  • Download the macOS installer from the release page.
  • Complete the installer using its default options.
  • Return to Visual Studio Code.
  • Repeat the version check above.

ⓧ Command not found

Your terminal cannot find Node.js yet. Install the pinned release before continuing.

  • Open the official Node.js release page for version 22.22.3.
  • Download the macOS installer from the release page.
  • Complete the installer using its default options.
  • Restart Visual Studio Code after the installation finishes.
  • Repeat the version check above.
Add the pinned extension configuration

The extension shell needs a reproducible package manifest before npm can install anything. The manifest pins every project dependency to a specific version.

  • Click the New File icon beside NEXTWORK-CONTEXT-BRIDGE in the Explorer sidebar.
  • Name the file package.json.
  • Paste the pinned project manifest into package.json using the code below:
{
  "name": "nextwork-context-bridge",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "wxt",
    "dev:firefox": "wxt -b firefox",
    "build": "wxt build",
    "build:firefox": "wxt build -b firefox",
    "zip": "wxt zip",
    "zip:firefox": "wxt zip -b firefox",
    "postinstall": "wxt prepare"
  },
  "dependencies": {
    "jszip": "3.10.1",
    "react": "19.2.8",
    "react-dom": "19.2.7"
  },
  "devDependencies": {
    "@tailwindcss/vite": "4.3.3",
    "@types/react": "19.3.0",
    "@types/react-dom": "19.3.0",
    "@wxt-dev/module-react": "1.2.2",
    "tailwindcss": "4.3.1",
    "typescript": "6.0.3",
    "vite": "8.3.0",
    "wxt": "0.21.4"
  }
}

What does this manifest define?

  • scripts defines the development and packaging commands for WXT.
  • jszip is pinned to 3.10.1 so the extension can inspect ZIP archives locally.
  • react and react-dom provide the sidebar interface.
  • devDependencies pins the extension build tools used on your Mac.
  • Save package.json by pressing Cmd+S (macOS) or Ctrl+S (Windows).
  • Confirm package.json appears under NEXTWORK-CONTEXT-BRIDGE in the Explorer sidebar.

Seeing a problem in package.json?

Check every comma and quotation mark against the reference. JSON rejects missing commas and trailing commas.

Ask for help with the manifest error: Help me find the JSON syntax problem in my package.json file.

WXT generates TypeScript settings inside its local project directory. Your project configuration extends those generated settings.

  • Click the New File icon beside NEXTWORK-CONTEXT-BRIDGE.
  • Name the file tsconfig.json.
  • Paste the TypeScript configuration into tsconfig.json using the code below:
{
  "extends": ".wxt/tsconfig.json"
}

What does this configuration do?

The extends setting makes the project inherit WXT's generated TypeScript configuration. The generated file appears after the project dependencies are prepared.

  • Save tsconfig.json by pressing Cmd+S (macOS) or Ctrl+S (Windows).
  • Confirm tsconfig.json appears beside package.json in the Explorer sidebar.

The final root-level file connects React and Tailwind CSS to WXT. It also declares the clipboard permission required by the later fallback path.

  • Click the New File icon beside NEXTWORK-CONTEXT-BRIDGE.
  • Name the file wxt.config.ts.
  • Paste the extension configuration into wxt.config.ts using the code below:
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "wxt";

export default defineConfig({
  modules: ["@wxt-dev/module-react"],
  vite: () => ({
    plugins: [tailwindcss()],
  }),
  manifest: {
    permissions: ["clipboardWrite"],
  },
});

What does this extension configuration do?

  • The @wxt-dev/module-react module enables React entrypoints.
  • The tailwindcss() plugin connects Tailwind CSS to Vite.
  • The clipboardWrite permission prepares the browser manifest for the later clipboard fallback.
  • Save wxt.config.ts by pressing Cmd+S (macOS) or Ctrl+S (Windows).
  • Confirm wxt.config.ts appears beside the two JSON files in the Explorer sidebar.

Seeing import errors in wxt.config.ts?

Import warnings are expected before the project packages are installed. Confirm that each package name matches package.json exactly.

Ask for help with a persistent configuration error: Why is my WXT configuration showing an import error?

Create and launch the side-panel entrypoint

WXT recognizes entrypoints/sidepanel/index.html as a side-panel entrypoint. The files beside it mount the React component and provide the first layer of styling.

  • Click the New File icon beside NEXTWORK-CONTEXT-BRIDGE.
  • Name the file entrypoints/sidepanel/index.html.
  • Paste the side-panel document into index.html using the code below:
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta name="manifest.open_at_install" content="true" />
    <title>NextWork Context Bridge</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="./main.tsx"></script>
  </body>
</html>

What does this document provide?

  • The root element gives React a place to render the sidebar.
  • The ./main.tsx module starts the React entrypoint.
  • The page title identifies the sidebar as NextWork Context Bridge.
  • Save entrypoints/sidepanel/index.html by pressing Cmd+S (macOS) or Ctrl+S (Windows).
  • Confirm the entrypoints folder now contains a sidepanel folder in the Explorer sidebar.

Did the nested folders fail to appear?

Check that the filename contains forward slashes between entrypoints and sidepanel. Confirm that index.html sits inside the final folder.

Ask for help with the file location: Help me place my side-panel HTML file in the correct folder.

The React root needs a small bootstrap file. This file stops immediately if the HTML document does not contain the expected container.

  • Select the sidepanel folder in the Explorer sidebar.
  • Click the New File icon.
  • Name the file main.tsx.
  • Paste the React bootstrap into main.tsx using the code below:
import { createRoot } from "react-dom/client";
import App from "./App";
import "./style.css";

const rootElement = document.getElementById("root");

if (!rootElement) {
  throw new Error("Root element not found");
}

createRoot(rootElement).render(<App />);

What does this bootstrap do?

  • The imports connect React DOM to the local component and stylesheet.
  • The rootElement lookup finds the container from index.html.
  • The explicit error catches a missing container before React tries to render.
  • The final line renders App inside the root element.
  • Save entrypoints/sidepanel/main.tsx by pressing Cmd+S (macOS) or Ctrl+S (Windows).
  • Confirm main.tsx appears beside index.html in the Explorer sidebar.

Seeing a root element error?

Confirm that index.html contains the root element. The identifier must match the lookup in main.tsx exactly.

Ask for help comparing the two files: Why can main.tsx not find my root element?

The first App component gives the extension a visible identity. Its compact header fits the narrow Firefox side panel.

  • Select the sidepanel folder in the Explorer sidebar.
  • Click the New File icon.
  • Name the file App.tsx.
  • Paste the initial sidebar component into App.tsx using the code below:
export default function App() {
  return (
    <main className="mx-auto grid w-full max-w-xl gap-3 p-3">
      <header className="space-y-1 py-2">
        <p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
          Local-only MVP
        </p>
        <h1 className="text-xl font-semibold">NextWork Context Bridge</h1>
        <p className="text-sm text-muted-foreground">
          Structure, inspect, split, and hand off context without a separate backend.
        </p>
      </header>
    </main>
  );
}

What does this component display?

  • The main element creates a narrow stacked layout for the sidebar.
  • The header displays the NextWork Context Bridge name.
  • The supporting text states that this version prepares context without a separate backend.
  • Save entrypoints/sidepanel/App.tsx by pressing Cmd+S (macOS) or Ctrl+S (Windows).
  • Confirm App.tsx appears beside main.tsx in the Explorer sidebar.

Seeing a JSX syntax warning?

Check the closing tags and parentheses against the reference. Confirm that the filename ends in .tsx.

Ask for help locating the mismatch: Help me find the JSX syntax problem in App.tsx.

The stylesheet loads Tailwind CSS and establishes the side panel's base dimensions. It also gives form controls the same inherited font as the document.

  • Select the sidepanel folder in the Explorer sidebar.
  • Click the New File icon.
  • Name the file style.css.
  • Paste the base sidebar styles into style.css using the code below:
@import "tailwindcss";

* {
  box-sizing: border-box;
}

html,
body,
#root {
  min-height: 100%;
  margin: 0;
}

body {
  min-width: 320px;
  font-family:
    Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
    "Segoe UI", sans-serif;
}

button,
input,
textarea {
  font: inherit;
}

What do these base styles provide?

  • The Tailwind CSS import makes its utility classes available to App.tsx.
  • The root document fills the available side-panel height.
  • The body keeps the interface usable at a narrow width.
  • The control rule keeps future buttons and fields consistent with the document font.
  • Save entrypoints/sidepanel/style.css by pressing Cmd+S (macOS) or Ctrl+S (Windows).
  • Confirm the sidepanel folder now contains all four entrypoint files in the Explorer sidebar.

Are the Tailwind styles missing later?

Confirm that the import is the first line of style.css. Check that main.tsx imports the same stylesheet path.

Ask for help tracing the stylesheet: Why is style.css not reaching my side panel?

✔️ Awesome, I've got everything!

Great. Your root configuration and side-panel entrypoint files are saved in the correct folders.

ⓧ I'd like to double check the full code

  • Compare each saved file with its complete Step 1 reference below.
{
  "name": "nextwork-context-bridge",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "wxt",
    "dev:firefox": "wxt -b firefox",
    "build": "wxt build",
    "build:firefox": "wxt build -b firefox",
    "zip": "wxt zip",
    "zip:firefox": "wxt zip -b firefox",
    "postinstall": "wxt prepare"
  },
  "dependencies": {
    "jszip": "3.10.1",
    "react": "19.2.8",
    "react-dom": "19.2.7"
  },
  "devDependencies": {
    "@tailwindcss/vite": "4.3.3",
    "@types/react": "19.3.0",
    "@types/react-dom": "19.3.0",
    "@wxt-dev/module-react": "1.2.2",
    "tailwindcss": "4.3.1",
    "typescript": "6.0.3",
    "vite": "8.3.0",
    "wxt": "0.21.4"
  }
}
{
  "extends": ".wxt/tsconfig.json"
}
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "wxt";

export default defineConfig({
  modules: ["@wxt-dev/module-react"],
  vite: () => ({
    plugins: [tailwindcss()],
  }),
  manifest: {
    permissions: ["clipboardWrite"],
  },
});
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta name="manifest.open_at_install" content="true" />
    <title>NextWork Context Bridge</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="./main.tsx"></script>
  </body>
</html>
import { createRoot } from "react-dom/client";
import App from "./App";
import "./style.css";

const rootElement = document.getElementById("root");

if (!rootElement) {
  throw new Error("Root element not found");
}

createRoot(rootElement).render(<App />);
export default function App() {
  return (
    <main className="mx-auto grid w-full max-w-xl gap-3 p-3">
      <header className="space-y-1 py-2">
        <p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
          Local-only MVP
        </p>
        <h1 className="text-xl font-semibold">NextWork Context Bridge</h1>
        <p className="text-sm text-muted-foreground">
          Structure, inspect, split, and hand off context without a separate backend.
        </p>
      </header>
    </main>
  );
}
@import "tailwindcss";

* {
  box-sizing: border-box;
}

html,
body,
#root {
  min-height: 100%;
  margin: 0;
}

body {
  min-width: 320px;
  font-family:
    Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
    "Segoe UI", sans-serif;
}

button,
input,
textarea {
  font: inherit;
}

npm now has a complete manifest to work from. Installing from the nextwork-context-bridge folder keeps every dependency local to this project.

  • Switch back to the integrated terminal in Visual Studio Code.
  • Install the project-local dependencies by running:
npm install

What does this install do?

npm reads package.json and installs its pinned dependencies into the local project. The postinstall script then asks WXT to prepare its generated files.

When installation finishes, the terminal returns to a prompt without a failure. The project-local packages are now ready for the development command.

Did npm install fail?

  • Confirm that the terminal path ends with nextwork-context-bridge.
  • Check package.json for a missing comma or quotation mark.
  • Confirm that your internet connection can reach the package registry.

Ask for help with the installation output: Help me diagnose my npm install failure.

Before you run the development command, which part of the extension shell do you expect WXT to expose in Firefox?

  • Launch the Firefox development build by running:
npm run dev:firefox

What does this development command do?

npm runs the dev:firefox script from package.json. WXT builds the extension for Firefox and starts its development profile.

The terminal remains attached to the development process so WXT can rebuild saved changes.

  • Leave the Visual Studio Code terminal running.
  • Switch to the Firefox development profile opened by WXT.
  • Check the side panel for the extension shell.

You should see a sidebar titled NextWork Context Bridge. The header also shows the local-only MVP label and its short context-handling description.

Did the Firefox sidebar fail to open?

  • Check the terminal for the first build error.
  • Confirm that all four files sit inside entrypoints/sidepanel.
  • Confirm that index.html loads ./main.tsx.

Ask for help with the launch output: Why did my WXT Firefox sidebar fail to launch?

That is the extension shell working. Your pinned WXT project can now build a React side panel inside Firefox.

Next, you will turn this minimal shell into the structured prompt sidebar.

Build the Structured Prompt Sidebar

The Firefox extension shell is live in its WXT development profile. The sidebar now needs a clear way to turn a rough request into useful instructions.

Free-form requests are easy to underspecify. You will use controlled React fields to capture Goal, Context, Output, and Boundaries in a stable order. Local Tailwind CSS tokens plus shadcn/ui-style Card components keep the narrow panel readable.

In this step, get ready to:
  • Build reusable Card components with neutral design tokens.
  • Add controlled fields for the four parts of a structured prompt.
  • Assemble non-empty fields into a live prompt preview.
Build the Card primitives

The sidebar needs reusable containers that remain consistent as later features arrive. The Card primitives apply the same spacing, borders, colors, and typography wherever the interface groups related controls.

  • In the Visual Studio Code Explorer from earlier, create a folder named lib inside nextwork-context-bridge.
  • Confirm that the lib folder appears beside entrypoints in the Explorer.
  • Create utils.ts inside the new lib folder.
  • Add the class-name utility to lib/utils.ts by pasting this code:
export function cn(
  ...classes: Array<string | false | null | undefined>
): string {
  return classes.filter(Boolean).join(" ");
}

What does this utility do?

  • The cn() function accepts class names plus optional false or empty values.
  • Filtering removes inactive values before the remaining class names are joined into one string.
  • This keeps conditional component styling readable as the sidebar grows.
  • Save lib/utils.ts in Visual Studio Code.
  • Confirm that the editor shows no TypeScript error underlines in the saved function.

Seeing a TypeScript error in cn?

Check that every union member in the array type is separated with a vertical bar. Confirm that filter(Boolean) appears before join(" ").

Ask for help with the exact file contents: Why is my cn utility showing a TypeScript error?

✔️ Awesome, I've got everything!

Your class-name utility is saved. It is ready for every Card primitive to reuse.

ⓧ I'd like to double check the full code

Compare your lib/utils.ts file with this complete version.

export function cn(
  ...classes: Array<string | false | null | undefined>
): string {
  return classes.filter(Boolean).join(" ");
}
  • Create a folder named components inside nextwork-context-bridge.
  • Create a folder named ui inside components.
  • Confirm that the Explorer shows the path components/ui.
  • Create card.tsx inside components/ui.
  • Add the base Card containers to components/ui/card.tsx by pasting this first section:
import * as React from "react";
import { cn } from "../../lib/utils";

export function Card({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="card"
      className={cn(
        "rounded-xl border border-border bg-card text-card-foreground shadow-sm",
        className,
      )}
      {...props}
    />
  );
}

export function CardHeader({
  className,
  ...props
}: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="card-header"
      className={cn("grid gap-1.5 p-4", className)}
      {...props}
    />
  );
}

What does this Card section do?

  • Each component accepts the normal properties for a div element.
  • The data-slot values identify each component role without affecting its appearance.
  • The cn() utility combines the default design with any extra class name supplied by the parent.
  • Save components/ui/card.tsx.
  • Confirm that the saved section has matching closing braces for Card and CardHeader.

Card import showing an error?

Confirm that card.tsx is inside components/ui. The relative utility import depends on that exact location.

Get help checking the path: Why can card.tsx not import cn from lib/utils?

  • Add the title components directly below CardHeader in components/ui/card.tsx by pasting this section:
export function CardTitle({
  className,
  ...props
}: React.ComponentProps<"h2">) {
  return (
    <h2
      data-slot="card-title"
      className={cn("text-sm font-semibold", className)}
      {...props}
    />
  );
}

export function CardDescription({
  className,
  ...props
}: React.ComponentProps<"p">) {
  return (
    <p
      data-slot="card-description"
      className={cn("text-xs text-muted-foreground", className)}
      {...props}
    />
  );
}

What do these title components add?

  • Card titles use a small semibold heading that fits the narrow sidebar.
  • Card descriptions use muted text to explain a group without competing with its controls.
  • Separate components preserve the correct heading and paragraph elements.
  • Save components/ui/card.tsx.
  • Confirm that the new exports appear below CardHeader without TypeScript error underlines.

Title components showing JSX errors?

Check that CardTitle returns an h2 element. Check that CardDescription returns a p element.

Ask for a focused comparison: Help me fix the JSX in my Card title components.

  • Add the content container at the bottom of components/ui/card.tsx by pasting this section:
export function CardContent({
  className,
  ...props
}: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="card-content"
      className={cn("px-4 pb-4", className)}
      {...props}
    />
  );
}

What does CardContent control?

CardContent provides shared horizontal and bottom spacing for form controls. Keeping that spacing in one component prevents every Card from inventing its own layout.

  • Save components/ui/card.tsx.
  • Confirm that the file now exports five Card primitives.

CardContent missing from imports later?

Confirm that CardContent includes the export keyword. Keep it outside the closing brace of CardDescription.

Get help with the export: Why can App.tsx not import CardContent?

✔️ Awesome, I've got everything!

Your five Card primitives now share one class-name utility and one visual language.

ⓧ I'd like to double check the full code

Compare your components/ui/card.tsx file with this complete version.

import * as React from "react";
import { cn } from "../../lib/utils";

export function Card({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="card"
      className={cn(
        "rounded-xl border border-border bg-card text-card-foreground shadow-sm",
        className,
      )}
      {...props}
    />
  );
}

export function CardHeader({
  className,
  ...props
}: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="card-header"
      className={cn("grid gap-1.5 p-4", className)}
      {...props}
    />
  );
}

export function CardTitle({
  className,
  ...props
}: React.ComponentProps<"h2">) {
  return (
    <h2
      data-slot="card-title"
      className={cn("text-sm font-semibold", className)}
      {...props}
    />
  );
}

export function CardDescription({
  className,
  ...props
}: React.ComponentProps<"p">) {
  return (
    <p
      data-slot="card-description"
      className={cn("text-xs text-muted-foreground", className)}
      {...props}
    />
  );
}

export function CardContent({
  className,
  ...props
}: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="card-content"
      className={cn("px-4 pb-4", className)}
      {...props}
    />
  );
}

The Card classes refer to named color tokens such as background and border. Defining those tokens centrally keeps every component on the same neutral palette.

  • Switch back to entrypoints/sidepanel/style.css from the extension shell.
  • Replace its current contents with the Tailwind import and theme mapping below:
@import "tailwindcss";

@theme inline {
  --color-background: var(--background);
  --color-foreground: var(--foreground);
  --color-card: var(--card);
  --color-card-foreground: var(--card-foreground);
  --color-primary: var(--primary);
  --color-primary-foreground: var(--primary-foreground);
  --color-secondary: var(--secondary);
  --color-secondary-foreground: var(--secondary-foreground);
  --color-muted: var(--muted);
  --color-muted-foreground: var(--muted-foreground);
  --color-border: var(--border);
  --color-input: var(--input);
  --color-ring: var(--ring);
}

What does the theme mapping do?

The theme mapping exposes CSS variables as Tailwind color utilities. A class such as bg-card can now read from the shared --card value.

  • Add the neutral color values directly below the theme mapping:
:root {
  color-scheme: light;
  --background: oklch(1 0 0);
  --foreground: oklch(0.145 0 0);
  --card: oklch(1 0 0);
  --card-foreground: oklch(0.145 0 0);
  --primary: oklch(0.205 0 0);
  --primary-foreground: oklch(0.985 0 0);
  --secondary: oklch(0.97 0 0);
  --secondary-foreground: oklch(0.205 0 0);
  --muted: oklch(0.97 0 0);
  --muted-foreground: oklch(0.556 0 0);
  --border: oklch(0.922 0 0);
  --input: oklch(0.922 0 0);
  --ring: oklch(0.708 0 0);
}

Why use neutral tokens?

The tokens assign a purpose to each color. Components can request a border or muted surface without carrying a hard-coded color value.

This open-code approach keeps the palette local to your project. You can inspect every value that shapes the sidebar.

  • Save entrypoints/sidepanel/style.css.
  • Return to the running Firefox sidebar to confirm that it remains loaded while the new theme compiles.

Sidebar styling disappeared?

Confirm that the first line is exactly @import "tailwindcss";. Check that the theme mapping closes before the :root block begins.

Ask for help with the stylesheet structure: Why did my Tailwind theme stop compiling?

  • Add the base document styles below the :root block:
* {
  box-sizing: border-box;
}

html,
body,
#root {
  min-height: 100%;
  margin: 0;
}

body {
  min-width: 320px;
  background: var(--background);
  color: var(--foreground);
  font-family:
    Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
    "Segoe UI", sans-serif;
}

button,
input,
textarea {
  font: inherit;
}

What do the base styles control?

  • Universal border sizing keeps padding inside each component's declared width.
  • The document fills the panel without the browser's default outer margin.
  • Buttons and form controls inherit the same font as the rest of the sidebar.
  • Save entrypoints/sidepanel/style.css.
  • Return to the Firefox sidebar to confirm that its page uses the neutral background and shared system font.

Sidebar wider than the panel?

Confirm that the universal selector uses box-sizing: border-box. Check that body has a minimum width of 320px.

Get help with the layout: Why is my sidebar overflowing after I added the base CSS?

✔️ Awesome, I've got everything!

The sidebar now has local neutral tokens plus predictable document sizing.

ⓧ I'd like to double check the full code

Compare your entrypoints/sidepanel/style.css file with this complete version.

@import "tailwindcss";

@theme inline {
  --color-background: var(--background);
  --color-foreground: var(--foreground);
  --color-card: var(--card);
  --color-card-foreground: var(--card-foreground);
  --color-primary: var(--primary);
  --color-primary-foreground: var(--primary-foreground);
  --color-secondary: var(--secondary);
  --color-secondary-foreground: var(--secondary-foreground);
  --color-muted: var(--muted);
  --color-muted-foreground: var(--muted-foreground);
  --color-border: var(--border);
  --color-input: var(--input);
  --color-ring: var(--ring);
}

:root {
  color-scheme: light;
  --background: oklch(1 0 0);
  --foreground: oklch(0.145 0 0);
  --card: oklch(1 0 0);
  --card-foreground: oklch(0.145 0 0);
  --primary: oklch(0.205 0 0);
  --primary-foreground: oklch(0.985 0 0);
  --secondary: oklch(0.97 0 0);
  --secondary-foreground: oklch(0.205 0 0);
  --muted: oklch(0.97 0 0);
  --muted-foreground: oklch(0.556 0 0);
  --border: oklch(0.922 0 0);
  --input: oklch(0.922 0 0);
  --ring: oklch(0.708 0 0);
}

* {
  box-sizing: border-box;
}

html,
body,
#root {
  min-height: 100%;
  margin: 0;
}

body {
  min-width: 320px;
  background: var(--background);
  color: var(--foreground);
  font-family:
    Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
    "Segoe UI", sans-serif;
}

button,
input,
textarea {
  font: inherit;
}
Add controlled prompt fields

A controlled field keeps its visible value in React state. Each edit updates one state variable, which gives the sidebar a reliable source for assembling the prompt.

  • Switch back to entrypoints/sidepanel/App.tsx from the running sidebar.
  • Replace the current imports and shared class constants with this first section:
import { useState } from "react";
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "../../components/ui/card";

const fieldClass =
  "min-h-20 w-full resize-y rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-ring";
const buttonClass =
  "rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground disabled:cursor-not-allowed disabled:opacity-50";
const secondaryButtonClass =
  "rounded-md border border-border bg-secondary px-3 py-2 text-sm font-medium text-secondary-foreground disabled:cursor-not-allowed disabled:opacity-50";

What does this App section prepare?

  • The React import makes state available to the component.
  • The Card imports provide the local layout pieces you just created.
  • The shared class constants keep fields and future buttons visually consistent.
  • Add the App component directly below the class constants:
export default function App() {
  const [goal, setGoal] = useState("");
  const [context, setContext] = useState("");
  const [output, setOutput] = useState("");
  const [boundaries, setBoundaries] = useState("");

  return (
    <main className="mx-auto grid w-full max-w-xl gap-3 p-3">
      <header className="space-y-1 py-2">
        <p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">Local-only MVP</p>
        <h1 className="text-xl font-semibold">NextWork Context Bridge</h1>
        <p className="text-sm text-muted-foreground">Structure, inspect, split, and hand off context without a separate backend.</p>
      </header>
      <Card>
        <CardHeader>
          <CardTitle>1. Structure the request</CardTitle>
          <CardDescription>Empty sections are omitted from the assembled prompt.</CardDescription>
        </CardHeader>
        <CardContent className="grid gap-3">
          <label className="grid gap-1 text-xs font-medium">Goal<textarea className={fieldClass} value={goal} onChange={(event) => setGoal(event.currentTarget.value)} placeholder="What should the AI accomplish?" /></label>
          <label className="grid gap-1 text-xs font-medium">Context<textarea className={fieldClass} value={context} onChange={(event) => setContext(event.currentTarget.value)} placeholder="What background should it know?" /></label>
          <label className="grid gap-1 text-xs font-medium">Output<textarea className={fieldClass} value={output} onChange={(event) => setOutput(event.currentTarget.value)} placeholder="What format and level of detail should it return?" /></label>
          <label className="grid gap-1 text-xs font-medium">Boundaries<textarea className={fieldClass} value={boundaries} onChange={(event) => setBoundaries(event.currentTarget.value)} placeholder="What must it avoid, preserve, or verify?" /></label>
        </CardContent>
      </Card>
    </main>
  );
}

How do the fields stay in sync?

  • Each useState() call owns the current text for one prompt section.
  • Each textarea reads its value from the matching state variable.
  • Each change handler stores the newest text after the learner types.
  • Save entrypoints/sidepanel/App.tsx.
  • Return to the running Firefox sidebar to confirm that one compact Card contains Goal, Context, Output, and Boundaries fields.
  • Type a short phrase into each field to confirm that the visible text stays in the correct field.

The sidebar now holds all four prompt sections in React state. That is the control layer the live preview needs.

Fields missing or refusing input?

Confirm that every textarea uses the matching value and setter pair. A Goal textarea must use goal with setGoal.

Ask for help matching the four pairs: Why are my controlled React textareas not updating?

Assemble the live prompt

A structured prompt separates the requested task from its supporting information. The builder trims each value, omits empty sections, and preserves one stable order for review.

  • Create a folder named utils inside nextwork-context-bridge.
  • Confirm that the utils folder appears beside components and entrypoints.
  • Create prompt.ts inside the new utils folder.
  • Add the prompt types and builder to utils/prompt.ts by pasting this code:
export type SourceFile = {
  id: string;
  name: string;
  text: string;
};

type PromptFields = {
  goal: string;
  context: string;
  output: string;
  boundaries: string;
  sources: SourceFile[];
};

export function buildPrompt(fields: PromptFields): string {
  const sections: string[] = [];

  const addSection = (label: string, value: string) => {
    const trimmed = value.trim();
    if (trimmed) sections.push(`${label}:\n${trimmed}`);
  };

  addSection("Goal", fields.goal);
  addSection("Context", fields.context);
  addSection("Output", fields.output);
  addSection("Boundaries", fields.boundaries);

  return sections.join("\n\n");
}

What does buildPrompt do?

  • The PromptFields type defines the values the builder accepts.
  • The local addSection() helper trims one value before adding its label.
  • An empty trimmed value never reaches the final sections array.
  • Joining the sections with blank lines makes their stable order easy to review.
  • Save utils/prompt.ts.
  • Confirm that the file shows no TypeScript error underlines.

Prompt builder showing an error?

Check that the template inside sections.push() uses backticks. Confirm that the newline characters remain inside that template.

Ask for help with the builder: Why is my buildPrompt function showing a TypeScript error?

✔️ Awesome, I've got everything!

Your builder can now turn the four state values into ordered labeled sections.

ⓧ I'd like to double check the full code

Compare your utils/prompt.ts file with this complete version.

export type SourceFile = {
  id: string;
  name: string;
  text: string;
};

type PromptFields = {
  goal: string;
  context: string;
  output: string;
  boundaries: string;
  sources: SourceFile[];
};

export function buildPrompt(fields: PromptFields): string {
  const sections: string[] = [];

  const addSection = (label: string, value: string) => {
    const trimmed = value.trim();
    if (trimmed) sections.push(`${label}:\n${trimmed}`);
  };

  addSection("Goal", fields.goal);
  addSection("Context", fields.context);
  addSection("Output", fields.output);
  addSection("Boundaries", fields.boundaries);

  return sections.join("\n\n");
}
  • In entrypoints/sidepanel/App.tsx replace the imports and state declarations at the top with this updated section:
import { useState } from "react";
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "../../components/ui/card";
import { buildPrompt } from "../../utils/prompt";

export default function App() {
  const [goal, setGoal] = useState("");
  const [context, setContext] = useState("");
  const [output, setOutput] = useState("");
  const [boundaries, setBoundaries] = useState("");

  const prompt = buildPrompt({ goal, context, output, boundaries, sources: [] });

How does App create the prompt?

The import gives App access to buildPrompt(). Every render passes the latest four field values into the builder.

The empty sources array reserves a place for local files in the next feature without changing today's output.

  • Inside CardContent add this preview directly below the Boundaries label:
          <label className="grid gap-1 text-xs font-medium">
            Assembled
            <textarea
              aria-label="Assembled prompt preview"
              className={`${fieldClass} min-h-56 font-mono text-xs`}
              readOnly
              value={prompt}
              placeholder="Your assembled prompt will appear here."
            />
          </label>

Why make the preview read-only?

The preview is derived from the four source fields. Keeping it read-only makes every visible line traceable to Goal, Context, Output, or Boundaries.

  • Save entrypoints/sidepanel/App.tsx.
  • Return to the running Firefox sidebar to load the updated Card.

Preview showing a blank panel?

Confirm that buildPrompt is imported from ../../utils/prompt. Check that the preview textarea uses value={prompt}.

Ask for help tracing the value: Why is my assembled prompt preview blank?

✔️ Awesome, I've got everything!

The Card layout, four controlled fields, and live assembled preview are connected.

ⓧ I'd like to double check the full code

Compare your entrypoints/sidepanel/App.tsx file with this complete version for the current step.

import { useState } from "react";
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "../../components/ui/card";
import { buildPrompt } from "../../utils/prompt";

const fieldClass =
  "min-h-20 w-full resize-y rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-ring";
const buttonClass =
  "rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground disabled:cursor-not-allowed disabled:opacity-50";
const secondaryButtonClass =
  "rounded-md border border-border bg-secondary px-3 py-2 text-sm font-medium text-secondary-foreground disabled:cursor-not-allowed disabled:opacity-50";

export default function App() {
  const [goal, setGoal] = useState("");
  const [context, setContext] = useState("");
  const [output, setOutput] = useState("");
  const [boundaries, setBoundaries] = useState("");

  const prompt = buildPrompt({ goal, context, output, boundaries, sources: [] });

  return (
    <main className="mx-auto grid w-full max-w-xl gap-3 p-3">
      <header className="space-y-1 py-2">
        <p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
          Local-only MVP
        </p>
        <h1 className="text-xl font-semibold">NextWork Context Bridge</h1>
        <p className="text-sm text-muted-foreground">
          Structure, inspect, split, and hand off context without a separate backend.
        </p>
      </header>

      <Card>
        <CardHeader>
          <CardTitle>1. Structure the request</CardTitle>
          <CardDescription>
            Empty sections are omitted from the assembled prompt.
          </CardDescription>
        </CardHeader>
        <CardContent className="grid gap-3">
          <label className="grid gap-1 text-xs font-medium">
            Goal
            <textarea
              className={fieldClass}
              value={goal}
              onChange={(event) => setGoal(event.currentTarget.value)}
              placeholder="What should the AI accomplish?"
            />
          </label>
          <label className="grid gap-1 text-xs font-medium">
            Context
            <textarea
              className={fieldClass}
              value={context}
              onChange={(event) => setContext(event.currentTarget.value)}
              placeholder="What background should it know?"
            />
          </label>
          <label className="grid gap-1 text-xs font-medium">
            Output
            <textarea
              className={fieldClass}
              value={output}
              onChange={(event) => setOutput(event.currentTarget.value)}
              placeholder="What format and level of detail should it return?"
            />
          </label>
          <label className="grid gap-1 text-xs font-medium">
            Boundaries
            <textarea
              className={fieldClass}
              value={boundaries}
              onChange={(event) => setBoundaries(event.currentTarget.value)}
              placeholder="What must it avoid, preserve, or verify?"
            />
          </label>
          <label className="grid gap-1 text-xs font-medium">
            Assembled
            <textarea
              aria-label="Assembled prompt preview"
              className={`${fieldClass} min-h-56 font-mono text-xs`}
              readOnly
              value={prompt}
              placeholder="Your assembled prompt will appear here."
            />
          </label>
        </CardContent>
      </Card>
    </main>
  );
}

Before you test the finished sidebar, which section order do you expect the preview to use?

  • Enter Build a launch checklist in Goal.
  • Enter The release is scheduled for Friday in Context.
  • Enter Return a numbered list in Output.
  • Enter Use only the supplied details in Boundaries.

The Assembled preview shows Goal, Context, Output, and Boundaries in that order. Each label contains the matching text you entered.

That is the first interactive slice complete. Your sidebar now turns four separate decisions into one reviewable prompt as you type.

Your structured prompt is live inside the Firefox sidebar. Next, you will add local text and source files so the prompt can carry real project context.

Load Local Sources

Your structured prompt now turns four controlled React fields into a stable request. That works well while every detail fits inside the sidebar.

Real project context often lives across several files. This step loads approved text files into memory. It also makes an oversized request visible before you try to send it as one message.

In this step, get ready to:
  • Wrap each local source with visible filename markers.
  • Load supported files into an in-memory source manifest.
  • Trigger a warning when the assembled text exceeds the local character target.
Wrap local sources with clear markers

A filename marker tells the receiving AI where one source begins. A matching end marker prevents adjacent files from blending together.

  • In the VS Code window from earlier, select utils/prompt.ts in the Explorer sidebar.
  • Find the final return sections.join("\n\n"); line inside buildPrompt().
  • Replace that final line with the source-marking block below:
  if (fields.sources.length > 0) {
    const sourceText = fields.sources
      .map(
        (source) =>
          `[SOURCE: ${source.name}]\n${source.text.trim()}\n[/SOURCE: ${source.name}]`,
      )
      .join("\n\n");

    sections.push(`Local Sources:\n${sourceText}`);
  }

  return sections.join("\n\n");

What Do the Markers Protect?

  • The opening marker includes source.name so each file stays identifiable inside the assembled prompt.
  • The closing marker makes the source boundary visible after the file text.
  • The Local Sources: section appears only when at least one source exists.
  • Save utils/prompt.ts.
  • Return to the running sidebar from earlier.
  • Type a short sentence into the Goal field.

The assembled preview still updates with your Goal text. This confirms the source-aware prompt builder preserves the existing structured fields.

Did the Sidebar Stop Updating?

Check that the new source block sits before the existing return statement inside buildPrompt().

Compare the brackets around fields.sources.length > 0 with the reference below.

Ask for help with the exact file and symptom: Help me find the syntax error in my source-marking block in utils/prompt.ts.

✔️ My Prompt Preview Still Updates

Good progress. Your prompt builder can now preserve clear boundaries around every source you load.

ⓧ I'd Like to Double Check the Full Code

Compare your complete utils/prompt.ts file with this reference.

export type SourceFile = {
  id: string;
  name: string;
  text: string;
};

type PromptFields = {
  goal: string;
  context: string;
  output: string;
  boundaries: string;
  sources: SourceFile[];
};

export function buildPrompt(fields: PromptFields): string {
  const sections: string[] = [];

  const addSection = (label: string, value: string) => {
    const trimmed = value.trim();
    if (trimmed) sections.push(`${label}:\n${trimmed}`);
  };

  addSection("Goal", fields.goal);
  addSection("Context", fields.context);
  addSection("Output", fields.output);
  addSection("Boundaries", fields.boundaries);

  if (fields.sources.length > 0) {
    const sourceText = fields.sources
      .map(
        (source) =>
          `[SOURCE: ${source.name}]\n${source.text.trim()}\n[/SOURCE: ${source.name}]`,
      )
      .join("\n\n");

    sections.push(`Local Sources:\n${sourceText}`);
  }

  return sections.join("\n\n");
}

export function chunkText(input: string, maxChars: number): string[] {
  const clean = input.trim();
  if (!clean) return [];

  const budget = Math.max(200, Math.floor(maxChars));
  const rawChunks: string[] = [];
  let current = "";

  for (const originalLine of clean.split("\n")) {
    let line = originalLine;

    while (line.length > budget) {
      if (current) {
        rawChunks.push(current);
        current = "";
      }
      rawChunks.push(line.slice(0, budget));
      line = line.slice(budget);
    }

    const candidate = current ? `${current}\n${line}` : line;
    if (candidate.length > budget && current) {
      rawChunks.push(current);
      current = line;
    } else {
      current = candidate;
    }
  }

  if (current) rawChunks.push(current);

  return rawChunks.map((chunk, index) => {
    const finalChunk = index === rawChunks.length - 1;
    const instruction = finalChunk
      ? "Final chunk. Use all chunks in order and complete the requested task."
      : "More chunks follow. Acknowledge receipt only.";

    return `[CONTEXT CHUNK ${index + 1}/${rawChunks.length}]\n${instruction}\n\n${chunk}`;
  });
}
Load supported files into memory

The browser File API gives your extension access only to files you select. File.text() reads each approved file as text for the current sidebar session.

  • Switch to entrypoints/sidepanel/App.tsx in the VS Code Explorer sidebar.
  • Add the local character target and supported-extension allowlist below the existing imports:
const DEFAULT_MAX_CHARS = 1200;
const TEXT_EXTENSIONS = new Set([
  ".txt",
  ".md",
  ".json",
  ".csv",
  ".yaml",
  ".yml",
  ".xml",
  ".html",
  ".css",
  ".js",
  ".jsx",
  ".ts",
  ".tsx",
  ".py",
  ".sql",
  ".sh",
]);

function isSupportedTextFile(name: string): boolean {
  const lowerName = name.toLowerCase();
  return Array.from(TEXT_EXTENSIONS).some((extension) =>
    lowerName.endsWith(extension),
  );
}

Why Use an Allowlist?

The allowlist keeps this text-only bridge focused on formats that File.text() can read directly. Parser-specific formats stay outside this project.

Converting the filename to lowercase lets files such as NOTES.TXT pass the same extension check.

  • Save entrypoints/sidepanel/App.tsx.
  • Return to the running sidebar from earlier.

The existing structured-request fields still respond to typing. This confirms the allowlist compiles without interrupting the running extension.

Did the Sidebar Disappear?

Check that every extension remains inside quotation marks. Confirm the final array entry is followed by the closing bracket.

Use the browser console message to locate the affected line in App.tsx.

Ask for targeted help: Help me debug the TEXT_EXTENSIONS allowlist in App.tsx.

  • Find the four controlled state declarations near the top of App.
  • Add the source session state immediately below boundaries:
  const [sources, setSources] = useState<SourceFile[]>([]);
  const [maxChars, setMaxChars] = useState(DEFAULT_MAX_CHARS);
  const [currentIndex, setCurrentIndex] = useState(0);
  const [status, setStatus] = useState("Ready");

What Does This State Hold?

  • The sources array holds the selected filenames and text in React memory.
  • The maxChars value starts at 1200 characters.
  • The currentIndex value resets when the assembled content changes.
  • The status value reports what the latest file action did.
  • Find the existing prompt calculation.
  • Replace it with the source-aware calculation below:
  const prompt = buildPrompt({ goal, context, output, boundaries, sources });
  const oversized = prompt.length > maxChars;

How Is the Limit Calculated?

The prompt now includes the selected source records. The oversized flag compares the complete character count with your local target.

  • Save entrypoints/sidepanel/App.tsx.
  • Return to the running sidebar from earlier.
  • Change the text in the Context field.

The assembled preview updates with the new Context text. The existing prompt flow now accepts an empty source collection.

Seeing a TypeScript Error?

Make sure the prompt calculation includes sources as its final field. Check that SourceFile is imported from ../../utils/prompt.

Ask for help with the state change: Help me connect SourceFile state to buildPrompt in App.tsx.

  • Add the file-loading function inside App above the returned JSX:
  async function addFiles(fileList: FileList | null) {
    if (!fileList) return;

    const chosen = Array.from(fileList);
    const accepted = chosen.filter((file) => isSupportedTextFile(file.name));
    const rejected = chosen.filter((file) => !isSupportedTextFile(file.name));

    const loaded = await Promise.all(
      accepted.map(async (file, index) => ({
        id: `${file.name}-${file.lastModified}-${file.size}-${index}`,
        name: file.name,
        text: await file.text(),
      })),
    );

    setSources((existing) => [...existing, ...loaded]);
    setCurrentIndex(0);
    setStatus(
      rejected.length > 0
        ? `Loaded ${loaded.length} file(s). Skipped: ${rejected
            .map((file) => file.name)
            .join(", ")}`
        : `Loaded ${loaded.length} local file(s).`,
    );
  }

What Does the Loader Keep?

  • The accepted and rejected arrays separate supported text files from everything else.
  • Each accepted file receives an identifier based on its name and browser-provided metadata.
  • The selected text enters sources after every accepted file finishes loading.
  • The status message names unsupported files that were skipped.
  • Add the demo-source and removal functions below addFiles():
  function addDemoSource() {
    const text = Array.from(
      { length: 70 },
      (_, index) =>
        `Demo line ${index + 1}: Keep this source local, preserve its filename, and process it in order.`,
    ).join("\n");

    setSources((existing) => [
      ...existing,
      {
        id: `demo-${Date.now()}`,
        name: "demo-context.txt",
        text,
      },
    ]);
    setCurrentIndex(0);
    setStatus("Demo source loaded. The single-message target should now be exceeded.");
  }

  function removeSource(id: string) {
    setSources((existing) => existing.filter((source) => source.id !== id));
    setCurrentIndex(0);
  }

Why Use Deterministic Demo Text?

The demo always creates 70 numbered lines. That gives every learner the same oversized input without exposing a personal file.

Removing a source filters its identifier out of React memory. The assembled prompt updates from the remaining records.

  • Add the local-source card below the structured-request card in the returned JSX:
      <Card>
        <CardHeader>
          <CardTitle>2. Add local sources</CardTitle>
          <CardDescription>
            Files stay in this sidebar's memory until it closes or reloads.
          </CardDescription>
        </CardHeader>
        <CardContent className="grid gap-3">
          <input
            aria-label="Choose local text and source files"
            type="file"
            multiple
            accept=".txt,.md,.json,.csv,.yaml,.yml,.xml,.html,.css,.js,.jsx,.ts,.tsx,.py,.sql,.sh"
            className="block w-full text-xs file:mr-2 file:rounded-md file:border-0 file:bg-secondary file:px-3 file:py-2 file:font-medium"
            onChange={(event) => {
              const input = event.currentTarget;
              void addFiles(input.files).finally(() => {
                input.value = "";
              });
            }}
          />
          <button className={secondaryButtonClass} type="button" onClick={addDemoSource}>
            Load demo source
          </button>
        </CardContent>
      </Card>

How Does the File Picker Behave?

The multiple input lets you select several files in one action. Its accept list mirrors the supported-extension allowlist.

Resetting the input value lets you select the same file again after the change event finishes.

  • Find the Load demo source button inside the new card.
  • Add the manifest immediately below that button:
          <ul className="grid gap-2">
            {sources.map((source) => (
              <li
                key={source.id}
                className="flex items-center justify-between gap-2 rounded-md border border-border px-3 py-2 text-xs"
              >
                <span className="min-w-0 truncate">{source.name}</span>
                <button
                  type="button"
                  className="text-muted-foreground hover:text-foreground"
                  onClick={() => removeSource(source.id)}
                >
                  Remove
                </button>
              </li>
            ))}
          </ul>

What Does the Manifest Show?

Each manifest row displays one selected filename. Its Remove button targets that source through its unique identifier.

The manifest displays names only. The file text stays inside the source record until the sidebar closes or reloads.

  • Save entrypoints/sidepanel/App.tsx.
  • Return to the running sidebar from earlier.

You now have a file picker and a Load demo source button beneath the structured fields. The source manifest starts empty.

Keep Sensitive Files Out

Selected files remain in the sidebar's memory while you review them. Anything you later send through NextWork is no longer local.

Choose only files you are allowed to share. Keep secrets and credentials out of the picker.

  • Click the local file input in the sidebar.
  • Select one or more supported files that contain no sensitive information.
  • Confirm your selection in the macOS file picker.

You will see each accepted filename in the source manifest. The assembled preview includes a Local Sources: section with matching opening and closing source markers.

  • Click Remove beside one source.

That filename disappears from the manifest. Its marked source text also disappears from the assembled preview.

Is a File Missing From the Manifest?

Check whether its extension appears in the supported list. Unsupported files are skipped and named in the status feedback.

Try a small .txt file to separate a format issue from a file-picker issue.

Ask for help with the selected format: Why did my selected file get skipped by the Context Bridge?

Expose the single-message limit

A local character target gives you a visible threshold for the assembled request. It is a queue control chosen inside this extension.

  • Find the card that contains the assembled prompt preview.
  • Add the character-target control above the preview:
          <label className="grid gap-1 text-xs font-medium">
            Character target per chunk
            <input
              type="number"
              min="200"
              step="100"
              value={maxChars}
              className="rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-ring"
              onChange={(event) => {
                const nextValue = Number(event.currentTarget.value);
                if (Number.isFinite(nextValue)) {
                  setMaxChars(Math.max(200, nextValue));
                  setCurrentIndex(0);
                }
              }}
            />
          </label>

How Does the Target Stay Valid?

The number input changes maxChars in local React state. The update keeps the value at 200 or higher.

Changing the target resets the current position so later reviews start from the beginning.

  • Add the character count and oversized warning below the target control:
          <div className="flex flex-wrap gap-2 text-xs">
            <span className="rounded-full bg-secondary px-2 py-1">
              {prompt.length} characters
            </span>
            {oversized && (
              <span className="rounded-full bg-amber-100 px-2 py-1 text-amber-900">
                Single message over target
              </span>
            )}
          </div>

When Does the Warning Appear?

The character badge reflects the full assembled prompt. The warning appears only when prompt.length exceeds maxChars.

The extension exposes the oversized request instead of trimming source text without your review.

  • Save entrypoints/sidepanel/App.tsx.
  • Return to the running sidebar from earlier.

The sidebar now shows a character target of 1200 plus the current prompt length. Before you load the demo source, do you expect all 70 lines to fit under that target?

  • Click Load demo source.

You will see demo-context.txt in the manifest. You will also see Single message over target beside a character count greater than 1200.

This Shortfall Is Intentional

The complete demo source remains visible between its filename markers. The warning proves that one assembled message cannot respect the current local target without another strategy.

🙋‍♀️ Did the Warning Stay Hidden?

Confirm the target field still shows 1200. Check that demo-context.txt appears in the manifest.

Make sure the warning condition uses prompt.length > maxChars.

Ask for help with the comparison: Why is my oversized warning missing after I load the demo source?

✔️ I See the Oversized Warning

That is the limitation exposed. Your bridge preserves every source while showing that the assembled request exceeds its local target.

ⓧ I'd Like to Double Check the Full Code

Compare your complete entrypoints/sidepanel/App.tsx file with this cumulative reference.

import { useState } from "react";
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "../../components/ui/card";
import { buildPrompt, type SourceFile } from "../../utils/prompt";

const DEFAULT_MAX_CHARS = 1200;
const TEXT_EXTENSIONS = new Set([
  ".txt",
  ".md",
  ".json",
  ".csv",
  ".yaml",
  ".yml",
  ".xml",
  ".html",
  ".css",
  ".js",
  ".jsx",
  ".ts",
  ".tsx",
  ".py",
  ".sql",
  ".sh",
]);

const fieldClass =
  "min-h-20 w-full resize-y rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-ring";
const buttonClass =
  "rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground disabled:cursor-not-allowed disabled:opacity-50";
const secondaryButtonClass =
  "rounded-md border border-border bg-secondary px-3 py-2 text-sm font-medium text-secondary-foreground disabled:cursor-not-allowed disabled:opacity-50";

function isSupportedTextFile(name: string): boolean {
  const lowerName = name.toLowerCase();
  return Array.from(TEXT_EXTENSIONS).some((extension) =>
    lowerName.endsWith(extension),
  );
}

export default function App() {
  const [goal, setGoal] = useState("");
  const [context, setContext] = useState("");
  const [output, setOutput] = useState("");
  const [boundaries, setBoundaries] = useState("");
  const [sources, setSources] = useState<SourceFile[]>([]);
  const [maxChars, setMaxChars] = useState(DEFAULT_MAX_CHARS);
  const [currentIndex, setCurrentIndex] = useState(0);
  const [status, setStatus] = useState("Ready");

  const prompt = buildPrompt({ goal, context, output, boundaries, sources });
  const oversized = prompt.length > maxChars;

  async function addFiles(fileList: FileList | null) {
    if (!fileList) return;

    const chosen = Array.from(fileList);
    const accepted = chosen.filter((file) => isSupportedTextFile(file.name));
    const rejected = chosen.filter((file) => !isSupportedTextFile(file.name));

    const loaded = await Promise.all(
      accepted.map(async (file, index) => ({
        id: `${file.name}-${file.lastModified}-${file.size}-${index}`,
        name: file.name,
        text: await file.text(),
      })),
    );

    setSources((existing) => [...existing, ...loaded]);
    setCurrentIndex(0);
    setStatus(
      rejected.length > 0
        ? `Loaded ${loaded.length} file(s). Skipped: ${rejected
            .map((file) => file.name)
            .join(", ")}`
        : `Loaded ${loaded.length} local file(s).`,
    );
  }

  function addDemoSource() {
    const text = Array.from(
      { length: 70 },
      (_, index) =>
        `Demo line ${index + 1}: Keep this source local, preserve its filename, and process it in order.`,
    ).join("\n");

    setSources((existing) => [
      ...existing,
      {
        id: `demo-${Date.now()}`,
        name: "demo-context.txt",
        text,
      },
    ]);
    setCurrentIndex(0);
    setStatus("Demo source loaded. The single-message target should now be exceeded.");
  }

  function removeSource(id: string) {
    setSources((existing) => existing.filter((source) => source.id !== id));
    setCurrentIndex(0);
  }

  return (
    <main className="mx-auto grid w-full max-w-xl gap-3 p-3">
      <header className="space-y-1 py-2">
        <p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
          Local-only MVP
        </p>
        <h1 className="text-xl font-semibold">NextWork Context Bridge</h1>
        <p className="text-sm text-muted-foreground">
          Structure, inspect, split, and hand off context without a separate backend.
        </p>
      </header>

      <Card>
        <CardHeader>
          <CardTitle>1. Structure the request</CardTitle>
          <CardDescription>
            Empty sections are omitted from the assembled prompt.
          </CardDescription>
        </CardHeader>
        <CardContent className="grid gap-3">
          <label className="grid gap-1 text-xs font-medium">
            Goal
            <textarea
              className={fieldClass}
              value={goal}
              onChange={(event) => {
                setGoal(event.currentTarget.value);
                setCurrentIndex(0);
              }}
              placeholder="What should the AI accomplish?"
            />
          </label>
          <label className="grid gap-1 text-xs font-medium">
            Context
            <textarea
              className={fieldClass}
              value={context}
              onChange={(event) => {
                setContext(event.currentTarget.value);
                setCurrentIndex(0);
              }}
              placeholder="What background should it know?"
            />
          </label>
          <label className="grid gap-1 text-xs font-medium">
            Output
            <textarea
              className={fieldClass}
              value={output}
              onChange={(event) => {
                setOutput(event.currentTarget.value);
                setCurrentIndex(0);
              }}
              placeholder="What format and level of detail should it return?"
            />
          </label>
          <label className="grid gap-1 text-xs font-medium">
            Boundaries
            <textarea
              className={fieldClass}
              value={boundaries}
              onChange={(event) => {
                setBoundaries(event.currentTarget.value);
                setCurrentIndex(0);
              }}
              placeholder="What must it avoid, preserve, or verify?"
            />
          </label>
        </CardContent>
      </Card>

      <Card>
        <CardHeader>
          <CardTitle>2. Add local sources</CardTitle>
          <CardDescription>
            Files stay in this sidebar's memory until it closes or reloads.
          </CardDescription>
        </CardHeader>
        <CardContent className="grid gap-3">
          <input
            aria-label="Choose local text and source files"
            type="file"
            multiple
            accept=".txt,.md,.json,.csv,.yaml,.yml,.xml,.html,.css,.js,.jsx,.ts,.tsx,.py,.sql,.sh"
            className="block w-full text-xs file:mr-2 file:rounded-md file:border-0 file:bg-secondary file:px-3 file:py-2 file:font-medium"
            onChange={(event) => {
              const input = event.currentTarget;
              void addFiles(input.files).finally(() => {
                input.value = "";
              });
            }}
          />
          <button className={secondaryButtonClass} type="button" onClick={addDemoSource}>
            Load demo source
          </button>
          <ul className="grid gap-2">
            {sources.map((source) => (
              <li
                key={source.id}
                className="flex items-center justify-between gap-2 rounded-md border border-border px-3 py-2 text-xs"
              >
                <span className="min-w-0 truncate">{source.name}</span>
                <button
                  type="button"
                  className="text-muted-foreground hover:text-foreground"
                  onClick={() => removeSource(source.id)}
                >
                  Remove
                </button>
              </li>
            ))}
          </ul>
        </CardContent>
      </Card>

      <Card>
        <CardHeader>
          <CardTitle>3. Review the queue</CardTitle>
          <CardDescription>
            The target is a local character budget, not a provider token limit.
          </CardDescription>
        </CardHeader>
        <CardContent className="grid gap-3">
          <label className="grid gap-1 text-xs font-medium">
            Character target per chunk
            <input
              type="number"
              min="200"
              step="100"
              value={maxChars}
              className="rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-ring"
              onChange={(event) => {
                const nextValue = Number(event.currentTarget.value);
                if (Number.isFinite(nextValue)) {
                  setMaxChars(Math.max(200, nextValue));
                  setCurrentIndex(0);
                }
              }}
            />
          </label>

          <div className="flex flex-wrap gap-2 text-xs">
            <span className="rounded-full bg-secondary px-2 py-1">
              {prompt.length} characters
            </span>
            {oversized && (
              <span className="rounded-full bg-amber-100 px-2 py-1 text-amber-900">
                Single message over target
              </span>
            )}
          </div>

          <textarea
            aria-label="Current context chunk preview"
            className={`${fieldClass} min-h-56 font-mono text-xs`}
            readOnly
            value={prompt}
            placeholder="Your assembled prompt will appear here."
          />

          <p className="rounded-md border border-border bg-muted p-3 text-xs text-muted-foreground">
            {status}
          </p>
        </CardContent>
      </Card>
    </main>
  );
}

You have a local source manifest that preserves file boundaries and exposes an oversized request without deleting text. Next, you will turn that warning into an ordered review queue.

Create a Reviewable Chunk Queue

Good progress. Your sidebar now keeps selected sources in memory and warns you with Single message over target when the assembled context grows too large.

That warning reveals a prompt that no longer fits the local character target. You will use deterministic text chunking to turn the oversized payload into an ordered review queue.

In this step, get ready to:
  • Split assembled prompt text at predictable boundaries.
  • Derive the current queue position from sidebar state.
  • Review every numbered chunk with manual navigation controls.
Split prompt text deterministically

A deterministic chunker produces the same pieces whenever its input and character target stay the same. Line boundaries keep source text readable while a hard split handles any individual line that exceeds the target.

  • Switch back to the utils/prompt.ts tab from earlier.
  • Select the existing chunkText() function.
  • Replace it with both code sections below before saving the file:
export function chunkText(input: string, maxChars: number): string[] {
  const clean = input.trim();
  if (!clean) return [];

  const budget = Math.max(200, Math.floor(maxChars));
  const rawChunks: string[] = [];
  let current = "";

  for (const originalLine of clean.split("\n")) {
    let line = originalLine;

    while (line.length > budget) {
      if (current) {
        rawChunks.push(current);
        current = "";
      }
      rawChunks.push(line.slice(0, budget));
      line = line.slice(budget);
    }

    const candidate = current ? `${current}\n${line}` : line;
    if (candidate.length > budget && current) {
      rawChunks.push(current);
      current = line;
    } else {
      current = candidate;
    }
  }

  if (current) rawChunks.push(current);

How does the split stay predictable?

  • The trimmed input returns an empty array when there is no prompt content.
  • The budget enforces a minimum of 200 characters after rounding the chosen target down.
  • The loop adds complete lines to current while they fit within the budget.
  • The inner loop slices a single oversized line into predictable pieces.
  • Place the remainder of chunkText() directly below the first section:
  return rawChunks.map((chunk, index) => {
    const finalChunk = index === rawChunks.length - 1;
    const instruction = finalChunk
      ? "Final chunk. Use all chunks in order and complete the requested task."
      : "More chunks follow. Acknowledge receipt only.";

    return `[CONTEXT CHUNK ${index + 1}/${rawChunks.length}]\n${instruction}\n\n${chunk}`;
  });
}

How does the queue gain meaning?

  • The mapping stage calculates whether each raw chunk is the final item.
  • Every result receives a position such as [CONTEXT CHUNK 1/4].
  • A non-final chunk asks for acknowledgement because more context follows.
  • The final chunk tells the receiving AI to use the complete ordered set.
  • Save utils/prompt.ts by pressing Cmd+S (macOS) or Ctrl+S (Windows).
  • Return to the running terminal from earlier to confirm the extension rebuild completes without a TypeScript error.

The chunking engine is now ready to produce stable queue items from the assembled prompt.

Seeing a TypeScript error in the chunker?

  • Confirm the second code section sits inside chunkText() after if (current) rawChunks.push(current);.
  • Check that the function ends with one closing brace after the rawChunks.map() call.

Help me compare my chunking function with the expected structure.

✔️ Awesome, I've got everything!

Your saved utils/prompt.ts now builds structured prompts and splits them into numbered queue items.

ⓧ I'd like to double check the full code

Compare your complete utils/prompt.ts file with this version:

export type SourceFile = {
  id: string;
  name: string;
  text: string;
};

type PromptFields = {
  goal: string;
  context: string;
  output: string;
  boundaries: string;
  sources: SourceFile[];
};

export function buildPrompt(fields: PromptFields): string {
  const sections: string[] = [];

  const addSection = (label: string, value: string) => {
    const trimmed = value.trim();
    if (trimmed) sections.push(`${label}:\n${trimmed}`);
  };

  addSection("Goal", fields.goal);
  addSection("Context", fields.context);
  addSection("Output", fields.output);
  addSection("Boundaries", fields.boundaries);

  if (fields.sources.length > 0) {
    const sourceText = fields.sources
      .map(
        (source) =>
          `[SOURCE: ${source.name}]\n${source.text.trim()}\n[/SOURCE: ${source.name}]`,
      )
      .join("\n\n");

    sections.push(`Local Sources:\n${sourceText}`);
  }

  return sections.join("\n\n");
}

export function chunkText(input: string, maxChars: number): string[] {
  const clean = input.trim();
  if (!clean) return [];

  const budget = Math.max(200, Math.floor(maxChars));
  const rawChunks: string[] = [];
  let current = "";

  for (const originalLine of clean.split("\n")) {
    let line = originalLine;

    while (line.length > budget) {
      if (current) {
        rawChunks.push(current);
        current = "";
      }
      rawChunks.push(line.slice(0, budget));
      line = line.slice(budget);
    }

    const candidate = current ? `${current}\n${line}` : line;
    if (candidate.length > budget && current) {
      rawChunks.push(current);
      current = line;
    } else {
      current = candidate;
    }
  }

  if (current) rawChunks.push(current);

  return rawChunks.map((chunk, index) => {
    const finalChunk = index === rawChunks.length - 1;
    const instruction = finalChunk
      ? "Final chunk. Use all chunks in order and complete the requested task."
      : "More chunks follow. Acknowledge receipt only.";

    return `[CONTEXT CHUNK ${index + 1}/${rawChunks.length}]\n${instruction}\n\n${chunk}`;
  });
}
Connect chunks to the sidebar

The sidebar needs a safe current position because editing the prompt can change the queue length. Derived values keep the displayed index valid whenever the queue becomes shorter.

  • Switch back to the entrypoints/sidepanel/App.tsx tab from earlier.
  • Update the prompt utility import so it includes chunkText and matches this line:
import { buildPrompt, chunkText, type SourceFile } from "../../utils/prompt";

Why import chunkText?

The sidebar already owns the assembled prompt and local maxChars target. Importing chunkText lets it derive the queue without storing another copy in state.

  • Find the line that creates prompt inside App.
  • Add the queue values directly below it:
  const chunks = chunkText(prompt, maxChars);
  const safeIndex = Math.min(currentIndex, Math.max(chunks.length - 1, 0));
  const currentChunk = chunks[safeIndex] ?? "";
  const oversized = prompt.length > maxChars;

What do these values control?

  • The chunks value recalculates whenever the prompt or target changes.
  • The safeIndex value prevents the preview from pointing beyond the final chunk.
  • The currentChunk value supplies the exact queue item shown in the preview.
  • The existing oversized value continues to control the single-message warning.
  • Add setCurrentIndex(0); after setGoal(event.currentTarget.value); in the Goal change handler.
  • Add setCurrentIndex(0); after setContext(event.currentTarget.value); in the Context change handler.
  • Add setCurrentIndex(0); after setOutput(event.currentTarget.value); in the Output change handler.
  • Add setCurrentIndex(0); after setBoundaries(event.currentTarget.value); in the Boundaries change handler.
  • Add setCurrentIndex(0); after setMaxChars(Math.max(200, nextValue)); in the character-target change handler.

The source-loading and removal functions already reset currentIndex when the source collection changes.

  • Find the badge row inside the queue review card.
  • Replace that row with this version to expose the derived chunk count:
          <div className="flex flex-wrap gap-2 text-xs">
            <span className="rounded-full bg-secondary px-2 py-1">
              {prompt.length} characters
            </span>
            <span className="rounded-full bg-secondary px-2 py-1">
              {chunks.length} chunk(s)
            </span>
            {oversized && (
              <span className="rounded-full bg-amber-100 px-2 py-1 text-amber-900">
                Single message over target
              </span>
            )}
          </div>

What does the new badge prove?

The chunk badge exposes the result of chunkText() beside the existing character count. A value above one confirms that the oversized prompt has become a queue.

  • Save entrypoints/sidepanel/App.tsx.
  • Return to the running Context Bridge sidebar.
  • Set Character target per chunk to 1200.
  • Click Load demo source.

You should see a chunk count above one beside the oversized warning. That is the original payload becoming a deterministic queue.

Still seeing zero or one chunk?

  • Confirm the character target is 1200.
  • Confirm demo-context.txt appears in the source manifest.
  • Check that chunks calls chunkText(prompt, maxChars).

Help me find why my demo source still produces only one chunk.

Review and advance the queue

A visible queue keeps every transfer under your control. Navigation changes the preview while Mark sent records manual progress without submitting anything automatically.

  • Find removeSource() above the component return statement.
  • Add markSent() directly below that function:
  function markSent() {
    if (chunks.length === 0) return;

    if (safeIndex >= chunks.length - 1) {
      setStatus("Queue complete. Keep submission manual and review the final message.");
      return;
    }

    setCurrentIndex(safeIndex + 1);
    setStatus(`Moved to chunk ${safeIndex + 2} of ${chunks.length}.`);
  }

How does manual advancement work?

  • An empty queue returns without changing the sidebar.
  • A non-final chunk advances currentIndex by one and reports the new position.
  • The final chunk leaves the index in place and reports that the queue is complete.
  • Find the badge row you updated inside the queue review card.
  • Add the navigation controls directly below that row:
          <div className="flex items-center justify-between text-xs text-muted-foreground">
            <button
              type="button"
              className={secondaryButtonClass}
              disabled={safeIndex === 0 || chunks.length === 0}
              onClick={() => setCurrentIndex(Math.max(0, safeIndex - 1))}
            >
              Previous
            </button>
            <span>
              {chunks.length === 0 ? 0 : safeIndex + 1} / {chunks.length}
            </span>
            <button
              type="button"
              className={secondaryButtonClass}
              disabled={chunks.length === 0 || safeIndex >= chunks.length - 1}
              onClick={() =>
                setCurrentIndex(Math.min(chunks.length - 1, safeIndex + 1))
              }
            >
              Next
            </button>
          </div>

How is navigation bounded?

The buttons clamp the index between the first and final queue positions. Their disabled states show when no earlier or later chunk exists.

  • Save entrypoints/sidepanel/App.tsx.
  • Return to the sidebar to confirm Previous and Next appear around the queue counter.

You should see Previous disabled at the first chunk. Next remains available because the demo source produces multiple chunks.

Navigation controls missing?

  • Confirm the navigation block sits inside the third CardContent element.
  • Check that the block appears after the closing tag for the badge row.

Help me place the queue navigation controls inside the correct card.

  • Add the read-only preview directly below the navigation controls:
          <textarea
            aria-label="Current context chunk preview"
            className={`${fieldClass} min-h-56 font-mono text-xs`}
            readOnly
            value={currentChunk}
            placeholder="Your assembled prompt will appear here."
          />

Why make the preview read-only?

The preview shows the exact prepared chunk without creating a second editable copy. Changes still come from the structured fields and selected sources.

  • Save entrypoints/sidepanel/App.tsx.
  • Return to the sidebar to confirm the preview begins with a numbered context-chunk header.

You should see the current queue position and its transfer instruction above the preserved prompt text.

Preview empty with a source loaded?

  • Confirm the preview uses value={currentChunk}.
  • Check that currentChunk reads from chunks[safeIndex].

Help me trace why currentChunk is empty while my demo source is loaded.

  • Add the manual advancement button directly below the preview:
            <button
              type="button"
              className={secondaryButtonClass}
              disabled={!currentChunk}
              onClick={markSent}
            >
              Mark sent
            </button>

What does Mark sent record?

The button advances the local review position after you decide a chunk has been handled. It never submits the chunk to NextWork.

  • Save entrypoints/sidepanel/App.tsx.

✔️ Awesome, I've got everything!

Your sidebar now derives a numbered queue and exposes manual controls for reviewing it.

ⓧ I'd like to double check the full code

Compare your complete entrypoints/sidepanel/App.tsx file with this version:

import { useState } from "react";
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "../../components/ui/card";
import { buildPrompt, chunkText, type SourceFile } from "../../utils/prompt";

const DEFAULT_MAX_CHARS = 1200;
const TEXT_EXTENSIONS = new Set([
  ".txt",
  ".md",
  ".json",
  ".csv",
  ".yaml",
  ".yml",
  ".xml",
  ".html",
  ".css",
  ".js",
  ".jsx",
  ".ts",
  ".tsx",
  ".py",
  ".sql",
  ".sh",
]);

const fieldClass =
  "min-h-20 w-full resize-y rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-ring";
const buttonClass =
  "rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground disabled:cursor-not-allowed disabled:opacity-50";
const secondaryButtonClass =
  "rounded-md border border-border bg-secondary px-3 py-2 text-sm font-medium text-secondary-foreground disabled:cursor-not-allowed disabled:opacity-50";

function isSupportedTextFile(name: string): boolean {
  const lowerName = name.toLowerCase();
  return Array.from(TEXT_EXTENSIONS).some((extension) =>
    lowerName.endsWith(extension),
  );
}

export default function App() {
  const [goal, setGoal] = useState("");
  const [context, setContext] = useState("");
  const [output, setOutput] = useState("");
  const [boundaries, setBoundaries] = useState("");
  const [sources, setSources] = useState<SourceFile[]>([]);
  const [maxChars, setMaxChars] = useState(DEFAULT_MAX_CHARS);
  const [currentIndex, setCurrentIndex] = useState(0);
  const [status, setStatus] = useState("Ready");

  const prompt = buildPrompt({ goal, context, output, boundaries, sources });
  const chunks = chunkText(prompt, maxChars);
  const safeIndex = Math.min(currentIndex, Math.max(chunks.length - 1, 0));
  const currentChunk = chunks[safeIndex] ?? "";
  const oversized = prompt.length > maxChars;

  async function addFiles(fileList: FileList | null) {
    if (!fileList) return;

    const chosen = Array.from(fileList);
    const accepted = chosen.filter((file) => isSupportedTextFile(file.name));
    const rejected = chosen.filter((file) => !isSupportedTextFile(file.name));

    const loaded = await Promise.all(
      accepted.map(async (file, index) => ({
        id: `${file.name}-${file.lastModified}-${file.size}-${index}`,
        name: file.name,
        text: await file.text(),
      })),
    );

    setSources((existing) => [...existing, ...loaded]);
    setCurrentIndex(0);
    setStatus(
      rejected.length > 0
        ? `Loaded ${loaded.length} file(s). Skipped: ${rejected
            .map((file) => file.name)
            .join(", ")}`
        : `Loaded ${loaded.length} local file(s).`,
    );
  }

  function addDemoSource() {
    const text = Array.from(
      { length: 70 },
      (_, index) =>
        `Demo line ${index + 1}: Keep this source local, preserve its filename, and process it in order.`,
    ).join("\n");

    setSources((existing) => [
      ...existing,
      {
        id: `demo-${Date.now()}`,
        name: "demo-context.txt",
        text,
      },
    ]);
    setCurrentIndex(0);
    setStatus("Demo source loaded. The single-message target should now be exceeded.");
  }

  function removeSource(id: string) {
    setSources((existing) => existing.filter((source) => source.id !== id));
    setCurrentIndex(0);
  }

  function markSent() {
    if (chunks.length === 0) return;

    if (safeIndex >= chunks.length - 1) {
      setStatus("Queue complete. Keep submission manual and review the final message.");
      return;
    }

    setCurrentIndex(safeIndex + 1);
    setStatus(`Moved to chunk ${safeIndex + 2} of ${chunks.length}.`);
  }

  return (
    <main className="mx-auto grid w-full max-w-xl gap-3 p-3">
      <header className="space-y-1 py-2">
        <p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
          Local-only MVP
        </p>
        <h1 className="text-xl font-semibold">NextWork Context Bridge</h1>
        <p className="text-sm text-muted-foreground">
          Structure, inspect, split, and hand off context without a separate backend.
        </p>
      </header>

      <Card>
        <CardHeader>
          <CardTitle>1. Structure the request</CardTitle>
          <CardDescription>
            Empty sections are omitted from the assembled prompt.
          </CardDescription>
        </CardHeader>
        <CardContent className="grid gap-3">
          <label className="grid gap-1 text-xs font-medium">
            Goal
            <textarea
              className={fieldClass}
              value={goal}
              onChange={(event) => {
                setGoal(event.currentTarget.value);
                setCurrentIndex(0);
              }}
              placeholder="What should the AI accomplish?"
            />
          </label>
          <label className="grid gap-1 text-xs font-medium">
            Context
            <textarea
              className={fieldClass}
              value={context}
              onChange={(event) => {
                setContext(event.currentTarget.value);
                setCurrentIndex(0);
              }}
              placeholder="What background should it know?"
            />
          </label>
          <label className="grid gap-1 text-xs font-medium">
            Output
            <textarea
              className={fieldClass}
              value={output}
              onChange={(event) => {
                setOutput(event.currentTarget.value);
                setCurrentIndex(0);
              }}
              placeholder="What format and level of detail should it return?"
            />
          </label>
          <label className="grid gap-1 text-xs font-medium">
            Boundaries
            <textarea
              className={fieldClass}
              value={boundaries}
              onChange={(event) => {
                setBoundaries(event.currentTarget.value);
                setCurrentIndex(0);
              }}
              placeholder="What must it avoid, preserve, or verify?"
            />
          </label>
        </CardContent>
      </Card>

      <Card>
        <CardHeader>
          <CardTitle>2. Add local sources</CardTitle>
          <CardDescription>
            Files stay in this sidebar's memory until it closes or reloads.
          </CardDescription>
        </CardHeader>
        <CardContent className="grid gap-3">
          <input
            aria-label="Choose local text and source files"
            type="file"
            multiple
            accept=".txt,.md,.json,.csv,.yaml,.yml,.xml,.html,.css,.js,.jsx,.ts,.tsx,.py,.sql,.sh"
            className="block w-full text-xs file:mr-2 file:rounded-md file:border-0 file:bg-secondary file:px-3 file:py-2 file:font-medium"
            onChange={(event) => {
              const input = event.currentTarget;
              void addFiles(input.files).finally(() => {
                input.value = "";
              });
            }}
          />
          <button className={secondaryButtonClass} type="button" onClick={addDemoSource}>
            Load demo source
          </button>
          <ul className="grid gap-2">
            {sources.map((source) => (
              <li
                key={source.id}
                className="flex items-center justify-between gap-2 rounded-md border border-border px-3 py-2 text-xs"
              >
                <span className="min-w-0 truncate">{source.name}</span>
                <button
                  type="button"
                  className="text-muted-foreground hover:text-foreground"
                  onClick={() => removeSource(source.id)}
                >
                  Remove
                </button>
              </li>
            ))}
          </ul>
        </CardContent>
      </Card>

      <Card>
        <CardHeader>
          <CardTitle>3. Review the queue</CardTitle>
          <CardDescription>
            The target is a local character budget, not a provider token limit.
          </CardDescription>
        </CardHeader>
        <CardContent className="grid gap-3">
          <label className="grid gap-1 text-xs font-medium">
            Character target per chunk
            <input
              type="number"
              min="200"
              step="100"
              value={maxChars}
              className="rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-ring"
              onChange={(event) => {
                const nextValue = Number(event.currentTarget.value);
                if (Number.isFinite(nextValue)) {
                  setMaxChars(Math.max(200, nextValue));
                  setCurrentIndex(0);
                }
              }}
            />
          </label>

          <div className="flex flex-wrap gap-2 text-xs">
            <span className="rounded-full bg-secondary px-2 py-1">
              {prompt.length} characters
            </span>
            <span className="rounded-full bg-secondary px-2 py-1">
              {chunks.length} chunk(s)
            </span>
            {oversized && (
              <span className="rounded-full bg-amber-100 px-2 py-1 text-amber-900">
                Single message over target
              </span>
            )}
          </div>

          <div className="flex items-center justify-between text-xs text-muted-foreground">
            <button
              type="button"
              className={secondaryButtonClass}
              disabled={safeIndex === 0 || chunks.length === 0}
              onClick={() => setCurrentIndex(Math.max(0, safeIndex - 1))}
            >
              Previous
            </button>
            <span>
              {chunks.length === 0 ? 0 : safeIndex + 1} / {chunks.length}
            </span>
            <button
              type="button"
              className={secondaryButtonClass}
              disabled={chunks.length === 0 || safeIndex >= chunks.length - 1}
              onClick={() =>
                setCurrentIndex(Math.min(chunks.length - 1, safeIndex + 1))
              }
            >
              Next
            </button>
          </div>

          <textarea
            aria-label="Current context chunk preview"
            className={`${fieldClass} min-h-56 font-mono text-xs`}
            readOnly
            value={currentChunk}
            placeholder="Your assembled prompt will appear here."
          />

            <button
              type="button"
              className={secondaryButtonClass}
              disabled={!currentChunk}
              onClick={markSent}
            >
              Mark sent
            </button>

          <p className="rounded-md border border-border bg-muted p-3 text-xs text-muted-foreground">
            {status}
          </p>
        </CardContent>
      </Card>
    </main>
  );
}

Before you test the controls, predict whether moving forward changes only the queue header or loses part of the prepared text.

  • Keep the character target at 1200.
  • Click Next once.

You should see the counter advance by one. The preview should begin with the matching numbered chunk header.

  • Click Previous once.

You should see the first chunk return with the same source text it contained before navigation.

  • Click Mark sent once.

You should move to the second queue item and see its new position in the status message.

  • Click Next until the final chunk is displayed.
  • Confirm the final preview contains Final chunk. Use all chunks in order and complete the requested task..

That completes the review loop. Every chunk remains visible in order while you decide when to advance.

Queue text changes or disappears?

  • Confirm the preview is read-only and uses currentChunk as its value.
  • Check that navigation updates only currentIndex.
  • Confirm field changes reset the index without modifying sources.

Help me debug why navigating my chunk queue changes or loses source text.

Your oversized context is now an ordered queue that stays intact during review. Next, you will connect the current chunk to a focused NextWork editor while keeping insertion under your control.

Insert a Prepared Chunk Into NextWork

Your review queue now preserves every chunk while you move through the source material. Each prepared chunk is ready for a deliberate handoff to NextWork.

Repeated copying still makes you the bridge between the sidebar and the message field. This step uses WebExtension messaging to connect both surfaces while keeping insertion under your control.

In this step, get ready to:
  • Track the last editable field you focus on a supported NextWork page.
  • Arm the reviewed chunk through the active Firefox tab.
  • Insert the chunk through a page button with a clipboard fallback.
Track the focused NextWork editor

A content script can observe the NextWork page while the sidebar remains separate. Tracking focus lets the extension work with standard editors without depending on a private page selector.

  • In the VS Code Explorer sidebar, hover over the entrypoints folder.
  • Click the New File icon.
  • Create nextwork.content.ts inside the entrypoints folder.
  • Add the supported-page matcher and focus tracker by pasting this code:
function isEditable(target: EventTarget | null): target is HTMLElement {
  if (target instanceof HTMLTextAreaElement) return true;

  if (target instanceof HTMLInputElement) {
    return ["text", "search", "url", "email", "tel"].includes(target.type);
  }

  return target instanceof HTMLElement && target.isContentEditable;
}

export default defineContentScript({
  matches: ["https://nextwork.ai/*", "https://learn.nextwork.ai/*"],

  main(ctx) {
    let lastEditable: HTMLElement | null = null;

    ctx.addEventListener(document, "focusin", (event) => {
      if (isEditable(event.target)) {
        lastEditable = event.target;
      }
    });
  },
});

How does focus tracking work?

  • The matches list restricts this script to the two supported NextWork hosts.
  • The isEditable() helper accepts textareas and supported text-like inputs.
  • The same helper accepts elements whose isContentEditable value is active.
  • The bubbling focusin event updates lastEditable whenever you focus a compatible editor.
  • Save entrypoints/nextwork.content.ts.
  • Return to the running VS Code terminal to confirm WXT rebuilds the extension without a TypeScript error.

The successful rebuild proves that WXT recognizes the new content-script entrypoint. The script can now remember the editor that should receive a prepared chunk.

Seeing a content-script build error?

  • Confirm the file is named exactly nextwork.content.ts inside entrypoints.
  • Check that both URL patterns end with /*.
  • Compare every closing brace with the code block above.

Help me diagnose the content-script build error.

Arm the current chunk from the sidebar

The sidebar and page run in different extension contexts. A typed message gives the sidebar a clear contract for transferring only the chunk you have reviewed.

  • In entrypoints/nextwork.content.ts, insert this message contract above isEditable():
import { browser } from "wxt/browser";

type ArmMessage = {
  type: "ARM_CONTEXT_CHUNK";
  text: string;
};

What does the message contract define?

The browser import provides WXT's unified extension API. The ArmMessage type requires a recognized message name plus the prepared text.

  • Save entrypoints/nextwork.content.ts.
  • Return to the running terminal to confirm the extension rebuild completes.
  • Inside main(ctx), add these state references directly below lastEditable:
    let pendingText: string | null = null;
    let actionButton: HTMLButtonElement | null = null;

What does the page remember?

The pendingText reference holds the armed chunk. The actionButton reference prevents duplicate page buttons.

  • Save entrypoints/nextwork.content.ts.
  • Check the running terminal for another successful rebuild.
  • Inside main(ctx), add this button function below the focusin listener:
    function ensureActionButton(): HTMLButtonElement {
      if (actionButton && document.contains(actionButton)) return actionButton;

      const button = document.createElement("button");
      button.type = "button";
      button.textContent = "Insert prepared chunk";
      button.setAttribute("aria-label", "Insert the prepared Context Bridge chunk");

      button.addEventListener("click", async () => {
        if (!pendingText) {
          button.textContent = "Arm a chunk first";
          return;
        }

        if (!lastEditable || !document.contains(lastEditable)) {
          button.textContent = "Focus the message field first";
          return;
        }
      });

      document.body.append(button);
      actionButton = button;
      return button;
    }

Why create the button on demand?

  • The existing-button check reuses the current control when another chunk is armed.
  • The first guard prevents an empty insertion attempt.
  • The second guard requires a compatible editor that still exists on the page.
  • Appending the button only after a message keeps the page unchanged until you arm a chunk.
  • Save entrypoints/nextwork.content.ts.
  • Check the running terminal for a successful rebuild.
  • Inside ensureActionButton(), add these styles directly below the aria-label line:
      Object.assign(button.style, {
        position: "fixed",
        right: "20px",
        bottom: "20px",
        zIndex: "2147483647",
        border: "1px solid #d4d4d8",
        borderRadius: "10px",
        background: "#18181b",
        color: "#ffffff",
        padding: "10px 14px",
        font: "600 14px system-ui",
        boxShadow: "0 8px 24px rgba(0, 0, 0, 0.2)",
        cursor: "pointer",
      });

Why use fixed button styles?

The fixed position keeps the review action visible near the lower-right corner. The high stacking order keeps it above the page without relying on NextWork's private styles.

  • Save entrypoints/nextwork.content.ts.
  • Confirm the running terminal rebuilds the extension successfully.
  • Inside main(ctx), add this message listener below ensureActionButton():
    browser.runtime.onMessage.addListener(
      (message: ArmMessage, _sender, sendResponse) => {
        if (message?.type !== "ARM_CONTEXT_CHUNK") return false;

        pendingText = message.text;
        ensureActionButton().textContent = "Insert prepared chunk";
        sendResponse({ ok: true });
        return false;
      },
    );

How does the page receive a chunk?

The listener ignores unrelated messages. A valid message stores its text before revealing the insertion button.

The synchronous sendResponse() call confirms that the page accepted the armed chunk. It does not submit anything to NextWork.

  • Save entrypoints/nextwork.content.ts.
  • Confirm the running terminal rebuilds the extension without an error.

The receiving side is ready. The sidebar now needs to find the active tab and send the current chunk.

  • In entrypoints/sidepanel/App.tsx, add the WXT browser import directly below the React import:
import { useState } from "react";
import { browser } from "wxt/browser";

What does the sidebar import enable?

The sidebar uses browser.tabs.query() to identify the active tab. It uses browser.tabs.sendMessage() to reach that tab's matching content script.

  • Save entrypoints/sidepanel/App.tsx.
  • Confirm the running terminal reports a successful rebuild.
  • In entrypoints/sidepanel/App.tsx, add this response type below secondaryButtonClass:
type ArmResponse = {
  ok: boolean;
  reason?: string;
};

Why type the response?

The ArmResponse shape gives the sidebar one success flag. Its optional reason carries a page-level rejection message when one exists.

  • Save entrypoints/sidepanel/App.tsx.
  • Check the running terminal for a successful rebuild.
  • In entrypoints/sidepanel/App.tsx, add this error helper below isSupportedTextFile():
function errorMessage(error: unknown): string {
  return error instanceof Error ? error.message : "Unknown extension error";
}

What does the error helper protect?

Caught values can have different shapes. The helper safely extracts a standard error message while preserving a readable fallback.

  • Save entrypoints/sidepanel/App.tsx.
  • Confirm the running terminal rebuilds the sidebar.
  • Inside App, add this initial armCurrentChunk() function below removeSource():
  async function armCurrentChunk() {
    if (!currentChunk) {
      setStatus("Add prompt content before arming a chunk.");
      return;
    }
  }

Why guard the current chunk?

An empty preview has no reviewed content to transfer. The guard stops the request before any tab lookup occurs.

  • Save entrypoints/sidepanel/App.tsx.
  • Confirm the running terminal rebuilds the sidebar successfully.
  • Inside armCurrentChunk(), add this request block below the empty-chunk guard:
    try {
      const [tab] = await browser.tabs.query({
        currentWindow: true,
        active: true,
      });

      if (tab?.id == null) {
        throw new Error("No active browser tab was found.");
      }

      const response = (await browser.tabs.sendMessage(tab.id, {
        type: "ARM_CONTEXT_CHUNK",
        text: currentChunk,
      })) as ArmResponse;

      if (!response?.ok) {
        throw new Error(response?.reason ?? "The page did not accept the chunk.");
      }

      setStatus(
        "Chunk armed. Focus the NextWork message field, then click Insert prepared chunk on the page.",
      );
    } catch (error) {
      setStatus(`Open a supported NextWork page and try again. ${errorMessage(error)}`);
    }

How does arming work?

  • The active-tab query limits the lookup to the current Firefox window.
  • The message contains the reviewed currentChunk plus its recognized type.
  • A positive response updates the sidebar status with the next manual action.
  • A failed lookup produces a readable status inside the extension.
  • Save entrypoints/sidepanel/App.tsx.
  • Confirm the running terminal rebuilds the sidebar without a TypeScript error.
  • In the queue card JSX, locate the existing Mark sent control.
  • Replace that control with this two-button group:
          <div className="grid grid-cols-2 gap-2">
            <button
              type="button"
              className={buttonClass}
              disabled={!currentChunk}
              onClick={() => void armCurrentChunk()}
            >
              Arm current chunk
            </button>
            <button
              type="button"
              className={secondaryButtonClass}
              disabled={!currentChunk}
              onClick={markSent}
            >
              Mark sent
            </button>
          </div>

Why keep both queue controls?

The primary control prepares the reviewed chunk for insertion. The secondary control keeps queue advancement manual after you send it.

  • Save entrypoints/sidepanel/App.tsx.
  • Confirm the running terminal rebuilds the extension successfully.
  • Return to the NextWork page in the WXT Firefox window.
  • Refresh the NextWork page so the updated content script loads.
  • Focus the NextWork message field.
  • Return to the Context Bridge sidebar.
  • Click Arm current chunk.

You should see the dark Insert prepared chunk button near the lower-right corner of the NextWork page. This confirms that the sidebar reached the active tab.

Don't see the page button?

  • Confirm the active tab uses one of the supported NextWork hosts.
  • Refresh the page after WXT finishes rebuilding the extension.
  • Focus the message field before returning to the sidebar.
  • Read the status below the sidebar controls for the reported cause.

Help me debug why the armed chunk does not reveal the page button.

Insert the chunk with a clipboard fallback

Arming proves the two extension contexts can communicate. The page button now needs to restore focus before inserting the reviewed text.

  • Inside the page button's click listener in entrypoints/nextwork.content.ts, add this standard-control insertion path below the two guard clauses:
        lastEditable.focus();
        let inserted = false;

        if (
          lastEditable instanceof HTMLTextAreaElement ||
          lastEditable instanceof HTMLInputElement
        ) {
          const start = lastEditable.selectionStart ?? lastEditable.value.length;
          const end = lastEditable.selectionEnd ?? start;
          lastEditable.setRangeText(pendingText, start, end, "end");
          lastEditable.dispatchEvent(new Event("input", { bubbles: true }));
          inserted = true;
        }

How does standard insertion work?

  • Calling focus() restores the editor selected before you returned to the sidebar.
  • The selection positions preserve the current cursor or highlighted range.
  • The setRangeText() call inserts the chunk at that range.
  • The bubbling input event tells the page that the field value changed.
  • Save entrypoints/nextwork.content.ts.
  • Confirm the running terminal rebuilds the content script successfully.
  • Inside the same click listener, add this success path directly below the standard-control insertion block:
        if (inserted) {
          pendingText = null;
          button.textContent = "Inserted";
          window.setTimeout(() => {
            button.remove();
            actionButton = null;
          }, 1200);
          return;
        }

What happens after insertion?

A successful insertion clears the pending chunk before changing the button label. The button removes itself after a short confirmation period.

  • Save entrypoints/nextwork.content.ts.
  • Confirm the running terminal rebuilds the extension.

Before you test this path, what do you expect the page button to place inside the focused message field?

  • Refresh the NextWork page.
  • Focus the NextWork message field.
  • Return to the sidebar.
  • Click Arm current chunk.
  • Return to the NextWork page.
  • Click Insert prepared chunk.

You should see the complete current chunk inside the focused field. The page button briefly reports Inserted before disappearing.

That closes the main interaction loop: you review a chunk in the sidebar before placing that exact chunk into NextWork.

  • Inside the standard-control condition in entrypoints/nextwork.content.ts, add this contenteditable branch before the success path:
        else if (lastEditable.isContentEditable) {
          const before = lastEditable.textContent ?? "";
          inserted = document.execCommand("insertText", false, pendingText);
          inserted = inserted || (lastEditable.textContent ?? "") !== before;
        }

Why include a contenteditable path?

Some editors use a contenteditable element instead of a standard form control. This branch attempts plain-text insertion before checking whether the editor content changed.

  • Save entrypoints/nextwork.content.ts.
  • Confirm the running terminal rebuilds the extension successfully.
  • Inside the click listener, add this clipboard fallback below the success path:
        try {
          await navigator.clipboard.writeText(pendingText);
          button.textContent = "Copied. Press Cmd+V";
        } catch {
          button.textContent = "Insertion and clipboard copy failed";
        }

How does the fallback preserve the chunk?

A rejected direct insertion leaves the pending text available for clipboard writing. The page button tells you to paste manually when the copy succeeds.

The final failure label keeps the problem visible if both browser paths are blocked. Your NextWork message is never submitted automatically.

  • Save entrypoints/nextwork.content.ts.
  • Confirm the running terminal completes its final extension rebuild.

Why keep a clipboard fallback?

The document.execCommand() compatibility path is deprecated. NextWork's editor behavior is also outside the extension's public contract.

The user-clicked page control plus clipboard fallback keeps the reviewed text recoverable when direct insertion is unavailable.

Before the final check, do you expect the extension to insert the reviewed chunk directly or preserve it for manual pasting?

  • Refresh the NextWork page after the final rebuild.
  • Focus the NextWork message field.
  • Return to the Context Bridge sidebar.
  • Click Arm current chunk.
  • Return to the NextWork page.
  • Click Insert prepared chunk.

You should see the current numbered chunk inside the message field. If the editor rejects direct insertion, you should see Copied. Press Cmd+V on the page button.

  • Press Cmd+V if the page button reports that the chunk was copied.
  • Confirm the same numbered chunk appears in the NextWork message field.
  • Keep the message unsent until you finish reviewing it.

Chunk missing or inserted in the wrong field?

  • Focus the intended NextWork message field immediately before returning to the sidebar.
  • Arm the chunk again if the page button reports that no chunk is ready.
  • Use Cmd+V when the page button reports a successful clipboard copy.
  • Refresh the page if the tracked editor was removed during a NextWork page update.

Help me troubleshoot the reviewed chunk insertion.

✔️ Awesome, I've got everything!

Your final content script and sidebar are saved. The reviewed chunk can now cross into NextWork through a deliberate page action.

ⓧ I'd like to double check the full code

Compare your final entrypoints/nextwork.content.ts with this complete file:

import { browser } from "wxt/browser";

type ArmMessage = {
  type: "ARM_CONTEXT_CHUNK";
  text: string;
};

function isEditable(target: EventTarget | null): target is HTMLElement {
  if (target instanceof HTMLTextAreaElement) return true;

  if (target instanceof HTMLInputElement) {
    return ["text", "search", "url", "email", "tel"].includes(target.type);
  }

  return target instanceof HTMLElement && target.isContentEditable;
}

export default defineContentScript({
  matches: ["https://nextwork.ai/*", "https://learn.nextwork.ai/*"],

  main(ctx) {
    let lastEditable: HTMLElement | null = null;
    let pendingText: string | null = null;
    let actionButton: HTMLButtonElement | null = null;

    ctx.addEventListener(document, "focusin", (event) => {
      if (isEditable(event.target)) {
        lastEditable = event.target;
      }
    });

    function ensureActionButton(): HTMLButtonElement {
      if (actionButton && document.contains(actionButton)) return actionButton;

      const button = document.createElement("button");
      button.type = "button";
      button.textContent = "Insert prepared chunk";
      button.setAttribute("aria-label", "Insert the prepared Context Bridge chunk");

      Object.assign(button.style, {
        position: "fixed",
        right: "20px",
        bottom: "20px",
        zIndex: "2147483647",
        border: "1px solid #d4d4d8",
        borderRadius: "10px",
        background: "#18181b",
        color: "#ffffff",
        padding: "10px 14px",
        font: "600 14px system-ui",
        boxShadow: "0 8px 24px rgba(0, 0, 0, 0.2)",
        cursor: "pointer",
      });

      button.addEventListener("click", async () => {
        if (!pendingText) {
          button.textContent = "Arm a chunk first";
          return;
        }

        if (!lastEditable || !document.contains(lastEditable)) {
          button.textContent = "Focus the message field first";
          return;
        }

        lastEditable.focus();
        let inserted = false;

        if (
          lastEditable instanceof HTMLTextAreaElement ||
          lastEditable instanceof HTMLInputElement
        ) {
          const start = lastEditable.selectionStart ?? lastEditable.value.length;
          const end = lastEditable.selectionEnd ?? start;
          lastEditable.setRangeText(pendingText, start, end, "end");
          lastEditable.dispatchEvent(new Event("input", { bubbles: true }));
          inserted = true;
        } else if (lastEditable.isContentEditable) {
          const before = lastEditable.textContent ?? "";
          inserted = document.execCommand("insertText", false, pendingText);
          inserted = inserted || (lastEditable.textContent ?? "") !== before;
        }

        if (inserted) {
          pendingText = null;
          button.textContent = "Inserted";
          window.setTimeout(() => {
            button.remove();
            actionButton = null;
          }, 1200);
          return;
        }

        try {
          await navigator.clipboard.writeText(pendingText);
          button.textContent = "Copied. Press Cmd+V";
        } catch {
          button.textContent = "Insertion and clipboard copy failed";
        }
      });

      document.body.append(button);
      actionButton = button;
      return button;
    }

    browser.runtime.onMessage.addListener(
      (message: ArmMessage, _sender, sendResponse) => {
        if (message?.type !== "ARM_CONTEXT_CHUNK") return false;

        pendingText = message.text;
        ensureActionButton().textContent = "Insert prepared chunk";
        sendResponse({ ok: true });
        return false;
      },
    );
  },
});

What should this file contain?

This file contains the supported-host restriction and focus tracker. It also contains the page button plus both insertion paths and the clipboard fallback.

Next, compare your final entrypoints/sidepanel/App.tsx with this complete file:

import { useState } from "react";
import { browser } from "wxt/browser";
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "../../components/ui/card";
import { buildPrompt, chunkText, type SourceFile } from "../../utils/prompt";

const DEFAULT_MAX_CHARS = 1200;
const TEXT_EXTENSIONS = new Set([
  ".txt",
  ".md",
  ".json",
  ".csv",
  ".yaml",
  ".yml",
  ".xml",
  ".html",
  ".css",
  ".js",
  ".jsx",
  ".ts",
  ".tsx",
  ".py",
  ".sql",
  ".sh",
]);

const fieldClass =
  "min-h-20 w-full resize-y rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-ring";
const buttonClass =
  "rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground disabled:cursor-not-allowed disabled:opacity-50";
const secondaryButtonClass =
  "rounded-md border border-border bg-secondary px-3 py-2 text-sm font-medium text-secondary-foreground disabled:cursor-not-allowed disabled:opacity-50";

type ArmResponse = {
  ok: boolean;
  reason?: string;
};

function isSupportedTextFile(name: string): boolean {
  const lowerName = name.toLowerCase();
  return Array.from(TEXT_EXTENSIONS).some((extension) =>
    lowerName.endsWith(extension),
  );
}

function errorMessage(error: unknown): string {
  return error instanceof Error ? error.message : "Unknown extension error";
}

export default function App() {
  const [goal, setGoal] = useState("");
  const [context, setContext] = useState("");
  const [output, setOutput] = useState("");
  const [boundaries, setBoundaries] = useState("");
  const [sources, setSources] = useState<SourceFile[]>([]);
  const [maxChars, setMaxChars] = useState(DEFAULT_MAX_CHARS);
  const [currentIndex, setCurrentIndex] = useState(0);
  const [status, setStatus] = useState("Ready");

  const prompt = buildPrompt({ goal, context, output, boundaries, sources });
  const chunks = chunkText(prompt, maxChars);
  const safeIndex = Math.min(currentIndex, Math.max(chunks.length - 1, 0));
  const currentChunk = chunks[safeIndex] ?? "";
  const oversized = prompt.length > maxChars;

  async function addFiles(fileList: FileList | null) {
    if (!fileList) return;

    const chosen = Array.from(fileList);
    const accepted = chosen.filter((file) => isSupportedTextFile(file.name));
    const rejected = chosen.filter((file) => !isSupportedTextFile(file.name));

    const loaded = await Promise.all(
      accepted.map(async (file, index) => ({
        id: `${file.name}-${file.lastModified}-${file.size}-${index}`,
        name: file.name,
        text: await file.text(),
      })),
    );

    setSources((existing) => [...existing, ...loaded]);
    setCurrentIndex(0);
    setStatus(
      rejected.length > 0
        ? `Loaded ${loaded.length} file(s). Skipped: ${rejected
            .map((file) => file.name)
            .join(", ")}`
        : `Loaded ${loaded.length} local file(s).`,
    );
  }

  function addDemoSource() {
    const text = Array.from(
      { length: 70 },
      (_, index) =>
        `Demo line ${index + 1}: Keep this source local, preserve its filename, and process it in order.`,
    ).join("\n");

    setSources((existing) => [
      ...existing,
      {
        id: `demo-${Date.now()}`,
        name: "demo-context.txt",
        text,
      },
    ]);
    setCurrentIndex(0);
    setStatus("Demo source loaded. The single-message target should now be exceeded.");
  }

  function removeSource(id: string) {
    setSources((existing) => existing.filter((source) => source.id !== id));
    setCurrentIndex(0);
  }

  async function armCurrentChunk() {
    if (!currentChunk) {
      setStatus("Add prompt content before arming a chunk.");
      return;
    }

    try {
      const [tab] = await browser.tabs.query({
        currentWindow: true,
        active: true,
      });

      if (tab?.id == null) {
        throw new Error("No active browser tab was found.");
      }

      const response = (await browser.tabs.sendMessage(tab.id, {
        type: "ARM_CONTEXT_CHUNK",
        text: currentChunk,
      })) as ArmResponse;

      if (!response?.ok) {
        throw new Error(response?.reason ?? "The page did not accept the chunk.");
      }

      setStatus(
        "Chunk armed. Focus the NextWork message field, then click Insert prepared chunk on the page.",
      );
    } catch (error) {
      setStatus(`Open a supported NextWork page and try again. ${errorMessage(error)}`);
    }
  }

  function markSent() {
    if (chunks.length === 0) return;

    if (safeIndex >= chunks.length - 1) {
      setStatus("Queue complete. Keep submission manual and review the final message.");
      return;
    }

    setCurrentIndex(safeIndex + 1);
    setStatus(`Moved to chunk ${safeIndex + 2} of ${chunks.length}.`);
  }

  return (
    <main className="mx-auto grid w-full max-w-xl gap-3 p-3">
      <header className="space-y-1 py-2">
        <p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
          Local-only MVP
        </p>
        <h1 className="text-xl font-semibold">NextWork Context Bridge</h1>
        <p className="text-sm text-muted-foreground">
          Structure, inspect, split, and hand off context without a separate backend.
        </p>
      </header>

      <Card>
        <CardHeader>
          <CardTitle>1. Structure the request</CardTitle>
          <CardDescription>
            Empty sections are omitted from the assembled prompt.
          </CardDescription>
        </CardHeader>
        <CardContent className="grid gap-3">
          <label className="grid gap-1 text-xs font-medium">
            Goal
            <textarea
              className={fieldClass}
              value={goal}
              onChange={(event) => {
                setGoal(event.currentTarget.value);
                setCurrentIndex(0);
              }}
              placeholder="What should the AI accomplish?"
            />
          </label>
          <label className="grid gap-1 text-xs font-medium">
            Context
            <textarea
              className={fieldClass}
              value={context}
              onChange={(event) => {
                setContext(event.currentTarget.value);
                setCurrentIndex(0);
              }}
              placeholder="What background should it know?"
            />
          </label>
          <label className="grid gap-1 text-xs font-medium">
            Output
            <textarea
              className={fieldClass}
              value={output}
              onChange={(event) => {
                setOutput(event.currentTarget.value);
                setCurrentIndex(0);
              }}
              placeholder="What format and level of detail should it return?"
            />
          </label>
          <label className="grid gap-1 text-xs font-medium">
            Boundaries
            <textarea
              className={fieldClass}
              value={boundaries}
              onChange={(event) => {
                setBoundaries(event.currentTarget.value);
                setCurrentIndex(0);
              }}
              placeholder="What must it avoid, preserve, or verify?"
            />
          </label>
        </CardContent>
      </Card>

      <Card>
        <CardHeader>
          <CardTitle>2. Add local sources</CardTitle>
          <CardDescription>
            Files stay in this sidebar's memory until it closes or reloads.
          </CardDescription>
        </CardHeader>
        <CardContent className="grid gap-3">
          <input
            aria-label="Choose local text and source files"
            type="file"
            multiple
            accept=".txt,.md,.json,.csv,.yaml,.yml,.xml,.html,.css,.js,.jsx,.ts,.tsx,.py,.sql,.sh"
            className="block w-full text-xs file:mr-2 file:rounded-md file:border-0 file:bg-secondary file:px-3 file:py-2 file:font-medium"
            onChange={(event) => {
              const input = event.currentTarget;
              void addFiles(input.files).finally(() => {
                input.value = "";
              });
            }}
          />
          <button className={secondaryButtonClass} type="button" onClick={addDemoSource}>
            Load demo source
          </button>
          <ul className="grid gap-2">
            {sources.map((source) => (
              <li
                key={source.id}
                className="flex items-center justify-between gap-2 rounded-md border border-border px-3 py-2 text-xs"
              >
                <span className="min-w-0 truncate">{source.name}</span>
                <button
                  type="button"
                  className="text-muted-foreground hover:text-foreground"
                  onClick={() => removeSource(source.id)}
                >
                  Remove
                </button>
              </li>
            ))}
          </ul>
        </CardContent>
      </Card>

      <Card>
        <CardHeader>
          <CardTitle>3. Review the queue</CardTitle>
          <CardDescription>
            The target is a local character budget, not a provider token limit.
          </CardDescription>
        </CardHeader>
        <CardContent className="grid gap-3">
          <label className="grid gap-1 text-xs font-medium">
            Character target per chunk
            <input
              type="number"
              min="200"
              step="100"
              value={maxChars}
              className="rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-ring"
              onChange={(event) => {
                const nextValue = Number(event.currentTarget.value);
                if (Number.isFinite(nextValue)) {
                  setMaxChars(Math.max(200, nextValue));
                  setCurrentIndex(0);
                }
              }}
            />
          </label>

          <div className="flex flex-wrap gap-2 text-xs">
            <span className="rounded-full bg-secondary px-2 py-1">
              {prompt.length} characters
            </span>
            <span className="rounded-full bg-secondary px-2 py-1">
              {chunks.length} chunk(s)
            </span>
            {oversized && (
              <span className="rounded-full bg-amber-100 px-2 py-1 text-amber-900">
                Single message over target
              </span>
            )}
          </div>

          <div className="flex items-center justify-between text-xs text-muted-foreground">
            <button
              type="button"
              className={secondaryButtonClass}
              disabled={safeIndex === 0 || chunks.length === 0}
              onClick={() => setCurrentIndex(Math.max(0, safeIndex - 1))}
            >
              Previous
            </button>
            <span>
              {chunks.length === 0 ? 0 : safeIndex + 1} / {chunks.length}
            </span>
            <button
              type="button"
              className={secondaryButtonClass}
              disabled={chunks.length === 0 || safeIndex >= chunks.length - 1}
              onClick={() =>
                setCurrentIndex(Math.min(chunks.length - 1, safeIndex + 1))
              }
            >
              Next
            </button>
          </div>

          <textarea
            aria-label="Current context chunk preview"
            className={`${fieldClass} min-h-56 font-mono text-xs`}
            readOnly
            value={currentChunk}
            placeholder="Your assembled prompt will appear here."
          />

          <div className="grid grid-cols-2 gap-2">
            <button
              type="button"
              className={buttonClass}
              disabled={!currentChunk}
              onClick={() => void armCurrentChunk()}
            >
              Arm current chunk
            </button>
            <button
              type="button"
              className={secondaryButtonClass}
              disabled={!currentChunk}
              onClick={markSent}
            >
              Mark sent
            </button>
          </div>

          <p className="rounded-md border border-border bg-muted p-3 text-xs text-muted-foreground">
            {status}
          </p>
        </CardContent>
      </Card>
    </main>
  );
}

What should this file contain?

This file retains the structured prompt and source queue from earlier steps. It adds active-tab messaging plus the Arm current chunk control.

Your reviewed chunk now reaches NextWork without automatic submission. Next, you will preserve complete project paths when the context arrives as a folder or ZIP archive.

Parse Folder and ZIP Structures

Your bridge can now place reviewed text in NextWork without submitting it for you. The next goal is to preserve the shape of an entire project instead of treating every file as an unrelated note.

A project tree gives filenames meaning because src/app.ts and tests/app.test.ts play different roles. You will load a selected folder or ZIP archive, keep safe relative paths, and expose the result before adding it to the prompt.

In this step, get ready to:
  • Preserve relative paths inside source markers.
  • Read approved text files from folders and ZIP archives.
  • Review a compact project tree before adding the bundle to the queue.
Preserve source paths

The current source model stores only a display name. Adding an optional path keeps existing file selections working while letting project bundles retain their hierarchy.

  • In utils/prompt.ts, find the existing SourceFile type.
  • Replace the current type with this path-aware version:
export type SourceFile = {
  id: string;
  name: string;
  path?: string;
  text: string;
};
  • Find the source mapping inside buildPrompt().
  • Replace that mapping with this path-aware version:
    const sourceText = fields.sources
      .map((source) => {
        // Prefer the relative path when a project bundle provides one.
        const label = source.path ?? source.name;
        return `[SOURCE: ${label}]\n${source.text.trim()}\n[/SOURCE: ${label}]`;
      })
      .join("\n\n");

Why preserve the relative path?

The path tells NextWork where a file sits inside the project. That context helps distinguish entrypoints, tests, configuration, and generated output.

  • Save utils/prompt.ts.
  • Check the running terminal for a successful rebuild.

Seeing a SourceFile error?

Check that path ends with a question mark in the type. Existing sources do not provide this field.

Need help with the update? Help me make SourceFile path-aware without breaking existing sources.

Read a folder or ZIP archive

Folder selection exposes files only after you choose a directory. The ZIP path uses JSZip 3.10.1 to inspect entries in memory. It sanitizes relative path components when loading an archive.

  • Create utils/bundles.ts inside the utils folder using the New File icon.
  • Add the shared limits and path filters by copying this code:
import JSZip from "jszip";
import type { SourceFile } from "./prompt";

// Keep browser memory use predictable for learner-sized projects.
const MAX_ARCHIVE_BYTES = 25_000_000;
const MAX_FILES = 250;
const MAX_TEXT_CHARS = 500_000;
const IGNORED_PARTS = new Set([".git", ".wxt", "dist", "node_modules"]);

function keepPath(path: string, isText: (name: string) => boolean): boolean {
  const parts = path.split("/").filter(Boolean);
  return isText(path) && !parts.some((part) => IGNORED_PARTS.has(part));
}

function sourceId(path: string, index: number): string {
  return `${path}-${index}`;
}

What do these limits protect?

  • The archive limit rejects a large ZIP before decompression starts.
  • The file limit prevents an unexpected dependency tree from filling sidebar memory.
  • The path filter removes common generated and dependency folders.
  • The text limit stops one extracted file from dominating the prompt.
  • Add the folder reader below sourceId() by copying this code:
export async function readFolder(
  files: FileList,
  isText: (name: string) => boolean,
): Promise<SourceFile[]> {
  const chosen = Array.from(files).filter((file) => {
    // Preserve supported files and skip generated folders.
    const path = file.webkitRelativePath || file.name;
    return keepPath(path, isText);
  });

  if (chosen.length > MAX_FILES) throw new Error("Folder contains too many files.");

  return Promise.all(chosen.map(async (file, index) => {
    const text = await file.text();
    if (text.length > MAX_TEXT_CHARS) throw new Error(`${file.name} is too large.`);
    const path = file.webkitRelativePath || file.name;
    return { id: sourceId(path, index), name: file.name, path, text };
  }));
}

How does folder reading work?

Each selected file supplies a user-approved path through webkitRelativePath when the browser provides it. The filename remains the fallback.

  • Add the ZIP reader below readFolder() by copying this code:
export async function readZip(
  file: File,
  isText: (name: string) => boolean,
): Promise<SourceFile[]> {
  if (file.size > MAX_ARCHIVE_BYTES) throw new Error("ZIP exceeds 25 MB.");

  // JSZip sanitizes upward relative path components while loading.
  const zip = await JSZip.loadAsync(await file.arrayBuffer(), { checkCRC32: true });
  const entries = Object.values(zip.files).filter(
    (entry) => !entry.dir && keepPath(entry.name, isText),
  );
  if (entries.length > MAX_FILES) throw new Error("ZIP contains too many files.");

  return Promise.all(entries.map(async (entry, index) => {
    const text = await entry.async("string");
    if (text.length > MAX_TEXT_CHARS) throw new Error(`${entry.name} is too large.`);
    return { id: sourceId(entry.name, index), name: entry.name.split("/").pop() ?? entry.name,
      path: `${file.name}/${entry.name}`, text };
  }));
}

What happens inside the archive?

JSZip rejects invalid, encrypted, and unsupported multi-volume archives. The reader keeps approved text entries under the archive name so their origin remains visible.

  • Add the tree formatter below readZip() by copying this code:
export function formatSourceTree(sources: SourceFile[]): string {
  // Sort paths so repeated imports produce the same visible tree.
  return sources
    .map((source) => source.path ?? source.name)
    .sort((left, right) => left.localeCompare(right))
    .map((path) => `- ${path}`)
    .join("\n");
}
  • Save utils/bundles.ts.
  • Check the running terminal for a successful rebuild.

✔️ Awesome, I've got everything!

Your bundle utility now reads folder selections, unpacks ZIP archives, and formats stable project paths.

ⓧ I'd like to double check the full code

import JSZip from "jszip";
import type { SourceFile } from "./prompt";

const MAX_ARCHIVE_BYTES = 25_000_000;
const MAX_FILES = 250;
const MAX_TEXT_CHARS = 500_000;
const IGNORED_PARTS = new Set([".git", ".wxt", "dist", "node_modules"]);

function keepPath(path: string, isText: (name: string) => boolean): boolean {
  const parts = path.split("/").filter(Boolean);
  return isText(path) && !parts.some((part) => IGNORED_PARTS.has(part));
}

function sourceId(path: string, index: number): string {
  return `${path}-${index}`;
}

export async function readFolder(
  files: FileList,
  isText: (name: string) => boolean,
): Promise<SourceFile[]> {
  const chosen = Array.from(files).filter((file) => {
    const path = file.webkitRelativePath || file.name;
    return keepPath(path, isText);
  });
  if (chosen.length > MAX_FILES) throw new Error("Folder contains too many files.");

  return Promise.all(chosen.map(async (file, index) => {
    const text = await file.text();
    if (text.length > MAX_TEXT_CHARS) throw new Error(`${file.name} is too large.`);
    const path = file.webkitRelativePath || file.name;
    return { id: sourceId(path, index), name: file.name, path, text };
  }));
}

export async function readZip(
  file: File,
  isText: (name: string) => boolean,
): Promise<SourceFile[]> {
  if (file.size > MAX_ARCHIVE_BYTES) throw new Error("ZIP exceeds 25 MB.");
  const zip = await JSZip.loadAsync(await file.arrayBuffer(), { checkCRC32: true });
  const entries = Object.values(zip.files).filter(
    (entry) => !entry.dir && keepPath(entry.name, isText),
  );
  if (entries.length > MAX_FILES) throw new Error("ZIP contains too many files.");

  return Promise.all(entries.map(async (entry, index) => {
    const text = await entry.async("string");
    if (text.length > MAX_TEXT_CHARS) throw new Error(`${entry.name} is too large.`);
    return { id: sourceId(entry.name, index), name: entry.name.split("/").pop() ?? entry.name,
      path: `${file.name}/${entry.name}`, text };
  }));
}

export function formatSourceTree(sources: SourceFile[]): string {
  return sources
    .map((source) => source.path ?? source.name)
    .sort((left, right) => left.localeCompare(right))
    .map((path) => `- ${path}`)
    .join("\n");
}
Connect and verify the bundle controls

The sidebar needs separate user actions for folders and ZIPs because the browser supplies them differently. Both paths still end in the same SourceFile[] model.

  • Import formatSourceTree, readFolder, and readZip into entrypoints/sidepanel/App.tsx.
  • Add a Choose project folder button that creates a file input with webkitdirectory and passes its files to readFolder().
  • Add an Import ZIP file input that accepts .zip and passes the selected file to readZip().
  • Append accepted results to sources and reset currentIndex after either import.
  • Render formatSourceTree(sources) in a read-only textarea below the source manifest.
import { formatSourceTree, readFolder, readZip } from "../../utils/bundles";

Why separate bundle parsing?

The three imports keep archive parsing outside the React component. The sidebar only coordinates selection, status, and visible state.

  • Add the folder-picker handler above addFiles() inside App.
  function chooseProjectFolder() {
    // Create the directory picker from a user click.
    const input = document.createElement("input");
    input.type = "file";
    input.multiple = true;
    input.setAttribute("webkitdirectory", "");
    input.addEventListener("change", () => {
      if (!input.files) return;
      void readFolder(input.files, isSupportedTextFile)
        .then((loaded) => {
          setSources((existing) => [...existing, ...loaded]);
          setCurrentIndex(0);
          setStatus(`Loaded ${loaded.length} file(s) from a folder.`);
        })
        .catch((error: unknown) => setStatus(errorMessage(error)));
    });
    input.click();
  }
  • Add the ZIP handler below chooseProjectFolder().
  async function chooseZip(file: File | undefined) {
    if (!file) return;

    try {
      // Convert approved archive entries into normal source records.
      const loaded = await readZip(file, isSupportedTextFile);
      setSources((existing) => [...existing, ...loaded]);
      setCurrentIndex(0);
      setStatus(`Loaded ${loaded.length} text file(s) from ${file.name}.`);
    } catch (error) {
      setStatus(errorMessage(error));
    }
  }
  • Add these controls below the existing local-file input in the Add local sources card:
          <button
            type="button"
            className={secondaryButtonClass}
            onClick={chooseProjectFolder}
          >
            Choose project folder
          </button>
          <label className="grid gap-1 text-xs font-medium">
            Import ZIP
            <input
              type="file"
              accept=".zip,application/zip"
              onChange={(event) => {
                const input = event.currentTarget;
                void chooseZip(input.files?.[0]).finally(() => {
                  input.value = "";
                });
              }}
            />
          </label>
  • Add this read-only project tree below the source manifest:
          <textarea
            aria-label="Imported project tree"
            className={`${fieldClass} min-h-32 font-mono text-xs`}
            readOnly
            value={formatSourceTree(sources)}
            placeholder="Imported project paths will appear here."
          />
  • Save entrypoints/sidepanel/App.tsx.
  • Return to the running sidebar and confirm the two new bundle controls appear.

Seeing a bundle-control error?

  • Confirm errorMessage() remains available above App.
  • Check that the new functions sit inside App before the returned JSX.
  • Check that the controls sit inside the local-source card.

Help me connect the bundle controls to App.tsx.

💡 Keep a ZIP fallback

Folder-picker behavior varies across browser versions. Keeping the ZIP input beside it gives every learner a path-preserving option.

Before you test the bundle controls, do you expect two files with the same name in different folders to remain distinguishable?

  • Select a small project folder with Choose project folder.
  • Use Import ZIP with the same project if your browser does not offer folder selection.
  • Review the source tree before opening the queue.

You should see each accepted file under its relative path. Files inside ignored folders should stay out of the tree and assembled prompt.

Is the project tree empty?

  • Confirm the project contains at least one extension from the text allowlist.
  • Use the ZIP control if folder selection is unavailable in your Firefox build.
  • Check the status for archive size, file-count, or text-size limits.

Need help tracing the import? Help me diagnose my empty project tree.

Your bridge can now explain where each source belongs inside a project. Next, you will add reusable prompt skills and turn selected images into one compressed visual sheet.

Add Skills and Visual Sheets

Your bridge now preserves the paths inside a project bundle. That structure tells NextWork where evidence lives, but it does not yet define how the receiving AI should use it.

A reusable skill packages those instructions as data. You will add an importable skill format, apply one skill to the prompt, and compress selected screenshots into a labelled visual sheet.

In this step, get ready to:
  • Define and validate reusable prompt skills.
  • Apply the selected skill to the structured prompt.
  • Build a compressed visual sheet for a manual NextWork attachment.
Create a reusable skill contract

A skill is a small JSON document that describes a repeatable way to inspect context. Keeping it separate from the component lets you add another workflow without rewriting the bridge.

  • Create utils/skills.ts inside the utils folder using the New File icon.
  • Add the skill contract and built-in project explainer by copying this code:
export type PromptSkill = {
  id: string;
  name: string;
  instructions: string;
  pathHints: string[];
  output: string;
  visual: { maxImages: number; columns: number; quality: number };
};

// This default turns a source tree into an architecture explanation.
export const PROJECT_EXPLAINER: PromptSkill = {
  id: "project-explainer",
  name: "Project explainer",
  instructions: "Explain the architecture and trace the main data flow.",
  pathHints: ["entrypoints", "src", "utils", "tests", "package.json"],
  output: "Return an overview, key-file table, data-flow steps, and open questions.",
  visual: { maxImages: 6, columns: 2, quality: 0.78 },
};

What belongs in a skill?

  • instructions states the analysis behavior.
  • pathHints names paths worth prioritizing without hiding other files.
  • output fixes the expected response shape.
  • visual controls the contact-sheet size and compression quality.
  • Add the skill parser below PROJECT_EXPLAINER by copying this code:
export function parseSkill(text: string): PromptSkill {
  // Parse untrusted local JSON before checking every required field.
  const value = JSON.parse(text) as Partial<PromptSkill>;
  if (!value.id || !value.name || !value.instructions || !value.output) {
    throw new Error("Skill is missing a required text field.");
  }
  if (!Array.isArray(value.pathHints) || !value.visual) {
    throw new Error("Skill is missing pathHints or visual settings.");
  }

  return {
    id: value.id,
    name: value.name,
    instructions: value.instructions,
    pathHints: value.pathHints.filter((item): item is string => typeof item === "string"),
    output: value.output,
    visual: {
      maxImages: Math.min(12, Math.max(1, value.visual.maxImages || 6)),
      columns: Math.min(3, Math.max(1, value.visual.columns || 2)),
      quality: Math.min(0.9, Math.max(0.4, value.visual.quality || 0.78)),
    },
  };
}
  • Add the prompt formatter below parseSkill() by copying this code:
export function formatSkill(skill: PromptSkill): string {
  // Keep the selected behavior visible inside the reviewed prompt.
  return [
    `Skill: ${skill.name}`,
    `Instructions: ${skill.instructions}`,
    `Prioritize paths: ${skill.pathHints.join(", ")}`,
    `Required output: ${skill.output}`,
  ].join("\n");
}
  • Save utils/skills.ts.
  • Check the running terminal for a successful rebuild.

Is a skill being rejected?

  • Validate every required field before adding an imported skill.
  • Reject a skill whose JSON cannot be parsed.
  • Keep the built-in project explainer selected while testing the feature.

Need help checking a skill? Help me validate my skill JSON.

✔️ Awesome, I've got everything!

Your skill module now provides a safe default plus validation for imported JSON skills.

ⓧ I'd like to double check the full code

export type PromptSkill = {
  id: string;
  name: string;
  instructions: string;
  pathHints: string[];
  output: string;
  visual: { maxImages: number; columns: number; quality: number };
};

export const PROJECT_EXPLAINER: PromptSkill = {
  id: "project-explainer",
  name: "Project explainer",
  instructions: "Explain the architecture and trace the main data flow.",
  pathHints: ["entrypoints", "src", "utils", "tests", "package.json"],
  output: "Return an overview, key-file table, data-flow steps, and open questions.",
  visual: { maxImages: 6, columns: 2, quality: 0.78 },
};

export function parseSkill(text: string): PromptSkill {
  const value = JSON.parse(text) as Partial<PromptSkill>;
  if (!value.id || !value.name || !value.instructions || !value.output) {
    throw new Error("Skill is missing a required text field.");
  }
  if (!Array.isArray(value.pathHints) || !value.visual) {
    throw new Error("Skill is missing pathHints or visual settings.");
  }
  return {
    id: value.id,
    name: value.name,
    instructions: value.instructions,
    pathHints: value.pathHints.filter((item): item is string => typeof item === "string"),
    output: value.output,
    visual: {
      maxImages: Math.min(12, Math.max(1, value.visual.maxImages || 6)),
      columns: Math.min(3, Math.max(1, value.visual.columns || 2)),
      quality: Math.min(0.9, Math.max(0.4, value.visual.quality || 0.78)),
    },
  };
}

export function formatSkill(skill: PromptSkill): string {
  return [
    `Skill: ${skill.name}`,
    `Instructions: ${skill.instructions}`,
    `Prioritize paths: ${skill.pathHints.join(", ")}`,
    `Required output: ${skill.output}`,
  ].join("\n");
}
Apply the selected skill

The skill must remain visible in the final message so you can review the exact behavior you are requesting. The bridge treats it as another named prompt section.

  • In utils/prompt.ts, add an optional skill string to PromptFields.
  • Add addSection("Skill", fields.skill ?? ""); before the Goal section.
  • In App.tsx, import PROJECT_EXPLAINER, formatSkill, and parseSkill.
  • Create skills state with the built-in skill as its first item.
  • Create selectedSkillId state with project-explainer as its initial value.
  • Pass formatSkill(selectedSkill) into buildPrompt() as the skill field.
type PromptFields = {
  skill?: string;
  goal: string;
  context: string;
  output: string;
  boundaries: string;
  sources: SourceFile[];
};
  • Place the Skill section before the existing Goal section with this line:
  addSection("Skill", fields.skill ?? "");
  • Save utils/prompt.ts.
  • Add these skill imports near the top of App.tsx.
import {
  PROJECT_EXPLAINER,
  formatSkill,
  parseSkill,
  type PromptSkill,
} from "../../utils/skills";
  • Add the skill state below the existing sidebar state declarations:
  const [skills, setSkills] = useState<PromptSkill[]>([PROJECT_EXPLAINER]);
  const [selectedSkillId, setSelectedSkillId] = useState(PROJECT_EXPLAINER.id);
  const selectedSkill =
    skills.find((skill) => skill.id === selectedSkillId) ?? PROJECT_EXPLAINER;
  • Replace the current buildPrompt() call with this skill-aware version:
  const prompt = buildPrompt({
    // Make the chosen behavior visible before the request is sent.
    skill: formatSkill(selectedSkill),
    goal,
    context,
    output,
    boundaries,
    sources,
  });
  • Add a Skill selector above the structured fields.
  • Add an Import skill JSON input beside the selector.
  • Read the selected file with file.text().
  • Pass the returned text to parseSkill() before adding it to skills.
  • Add this import handler inside App before the returned JSX:
  async function importSkill(file: File | undefined) {
    if (!file) return;

    try {
      // Validate the local JSON before exposing it in the selector.
      const imported = parseSkill(await file.text());
      setSkills((existing) => [
        ...existing.filter((skill) => skill.id !== imported.id),
        imported,
      ]);
      setSelectedSkillId(imported.id);
      setStatus(`Imported skill: ${imported.name}.`);
    } catch (error) {
      setStatus(errorMessage(error));
    }
  }

What does the import handler do?

  • parseSkill() rejects incomplete JSON before it reaches application state.
  • Matching skill IDs are replaced so the selector never shows duplicate entries.
  • The imported skill becomes active immediately, which makes the preview update visible.
  • Add this card above the structured prompt fields in the returned JSX:
        <Card>
          <CardHeader>
            <CardTitle>Prompt skill</CardTitle>
          </CardHeader>
          <CardContent className="grid gap-3">
            <label className="grid gap-1 text-xs font-medium">
              Skill
              <select
                className={fieldClass}
                value={selectedSkillId}
                onChange={(event) => setSelectedSkillId(event.target.value)}
              >
                {skills.map((skill) => (
                  <option key={skill.id} value={skill.id}>{skill.name}</option>
                ))}
              </select>
            </label>
            <label className="grid gap-1 text-xs font-medium">
              Import skill JSON
              <input
                type="file"
                accept="application/json,.json"
                onChange={(event) => void importSkill(event.currentTarget.files?.[0])}
              />
            </label>
          </CardContent>
        </Card>
  • Save entrypoints/sidepanel/App.tsx.
  • Return to the sidebar and select Project explainer.
  • Confirm the prompt preview starts with a Skill section.

Does the selected skill stay unchanged?

  • Check that the imported file uses valid JSON syntax.
  • Confirm the skill includes id, name, instructions, pathHints, output, and visual.
  • Confirm the handler sits inside App before the returned JSX.

Help me trace the selected skill into the prompt.

You should see Project explainer in the selector. The assembled preview should begin with a Skill section that names its instructions and required output.

Build the compressed visual sheet

Several screenshots can overwhelm a chat attachment area. A contact sheet keeps their filenames visible while reducing the number of files you need to review and attach.

  • Create utils/visual.ts inside the utils folder using the New File icon.
  • Add the visual-input type and image filter by copying this code:
import JSZip from "jszip";

export type VisualInput = { path: string; blob: Blob };
const IMAGE_PATTERN = /\.(png|jpe?g|webp)$/i;
const MAX_VISUAL_ARCHIVE_BYTES = 25_000_000;
const MAX_VISUAL_INPUTS = 12;

export function imagesFromFiles(files: FileList): VisualInput[] {
  // Preserve folder paths while limiting decoded browser memory.
  return Array.from(files)
    .filter((file) => IMAGE_PATTERN.test(file.name))
    .slice(0, MAX_VISUAL_INPUTS)
    .map((file) => ({ path: file.webkitRelativePath || file.name, blob: file }));
}

export async function imagesFromZip(file: File): Promise<VisualInput[]> {
  if (file.size > MAX_VISUAL_ARCHIVE_BYTES) throw new Error("ZIP exceeds 25 MB.");

  // Reuse JSZip's sanitized names and keep only the first approved images.
  const zip = await JSZip.loadAsync(await file.arrayBuffer(), { checkCRC32: true });
  const entries = Object.values(zip.files)
    .filter((entry) => !entry.dir && IMAGE_PATTERN.test(entry.name))
    .slice(0, MAX_VISUAL_INPUTS);
  return Promise.all(entries.map(async (entry) => ({
    path: `${file.name}/${entry.name}`,
    blob: await entry.async("blob"),
  })));
}
  • Add the visual-sheet renderer below imagesFromZip() by copying this code:
export async function createVisualSheet(
  inputs: VisualInput[], maxImages: number, columns: number, quality: number,
): Promise<File> {
  const chosen = inputs.slice(0, maxImages);
  if (chosen.length === 0) throw new Error("Choose at least one supported image.");

  // Decode approved local blobs before drawing a labelled grid.
  const bitmaps = await Promise.all(chosen.map((item) => createImageBitmap(item.blob)));
  const cellWidth = 600;
  const cellHeight = 420;
  const canvas = document.createElement("canvas");
  canvas.width = cellWidth * columns;
  canvas.height = cellHeight * Math.ceil(chosen.length / columns);
  const context = canvas.getContext("2d");
  if (!context) throw new Error("Canvas is unavailable.");
  context.fillStyle = "#ffffff";
  context.fillRect(0, 0, canvas.width, canvas.height);

  bitmaps.forEach((bitmap, index) => {
    const x = (index % columns) * cellWidth;
    const y = Math.floor(index / columns) * cellHeight;
    const scale = Math.min(560 / bitmap.width, 340 / bitmap.height);
    context.drawImage(bitmap, x + 20, y + 20, bitmap.width * scale, bitmap.height * scale);
    context.fillStyle = "#18181b";
    context.font = "16px system-ui";
    context.fillText(chosen[index].path.slice(-64), x + 20, y + 395);
    bitmap.close();
  });
  • Complete createVisualSheet() directly below the drawing loop:
  const blob = await new Promise<Blob>((resolve, reject) => {
    // WebP reduces size while the quality value remains skill-controlled.
    canvas.toBlob(
      (result) => result ? resolve(result) : reject(new Error("Image encoding failed.")),
      "image/webp",
      quality,
    );
  });

  return new File([blob], "nextwork-visual-sheet.webp", { type: blob.type });
}

How is the sheet compressed?

The browser decodes each approved local image with createImageBitmap(). Canvas draws a labelled grid, then toBlob() exports one compressed WebP file.

The built-in skill caps the sheet at six images in two columns. Its 0.78 quality value balances text readability with file size.

  • Save utils/visual.ts.
  • Check the running terminal for a successful rebuild.

✔️ Awesome, I've got everything!

Your visual utility now reads approved images from files or ZIP entries and exports one labelled WebP sheet.

ⓧ I'd like to double check the full code

import JSZip from "jszip";

export type VisualInput = { path: string; blob: Blob };
const IMAGE_PATTERN = /\.(png|jpe?g|webp)$/i;
const MAX_VISUAL_ARCHIVE_BYTES = 25_000_000;
const MAX_VISUAL_INPUTS = 12;

export function imagesFromFiles(files: FileList): VisualInput[] {
  // Preserve folder paths while limiting decoded browser memory.
  return Array.from(files)
    .filter((file) => IMAGE_PATTERN.test(file.name))
    .slice(0, MAX_VISUAL_INPUTS)
    .map((file) => ({ path: file.webkitRelativePath || file.name, blob: file }));
}

export async function imagesFromZip(file: File): Promise<VisualInput[]> {
  if (file.size > MAX_VISUAL_ARCHIVE_BYTES) throw new Error("ZIP exceeds 25 MB.");

  // Reuse JSZip's sanitized names and keep only the first approved images.
  const zip = await JSZip.loadAsync(await file.arrayBuffer(), { checkCRC32: true });
  const entries = Object.values(zip.files)
    .filter((entry) => !entry.dir && IMAGE_PATTERN.test(entry.name))
    .slice(0, MAX_VISUAL_INPUTS);
  return Promise.all(entries.map(async (entry) => ({
    path: `${file.name}/${entry.name}`,
    blob: await entry.async("blob"),
  })));
}

export async function createVisualSheet(
  inputs: VisualInput[], maxImages: number, columns: number, quality: number,
): Promise<File> {
  const chosen = inputs.slice(0, maxImages);
  if (chosen.length === 0) throw new Error("Choose at least one supported image.");

  // Decode approved local blobs before drawing a labelled grid.
  const bitmaps = await Promise.all(chosen.map((item) => createImageBitmap(item.blob)));
  const cellWidth = 600;
  const cellHeight = 420;
  const canvas = document.createElement("canvas");
  canvas.width = cellWidth * columns;
  canvas.height = cellHeight * Math.ceil(chosen.length / columns);
  const context = canvas.getContext("2d");
  if (!context) throw new Error("Canvas is unavailable.");
  context.fillStyle = "#ffffff";
  context.fillRect(0, 0, canvas.width, canvas.height);

  bitmaps.forEach((bitmap, index) => {
    const x = (index % columns) * cellWidth;
    const y = Math.floor(index / columns) * cellHeight;
    const scale = Math.min(560 / bitmap.width, 340 / bitmap.height);
    context.drawImage(bitmap, x + 20, y + 20, bitmap.width * scale, bitmap.height * scale);
    context.fillStyle = "#18181b";
    context.font = "16px system-ui";
    context.fillText(chosen[index].path.slice(-64), x + 20, y + 395);
    bitmap.close();
  });

  const blob = await new Promise<Blob>((resolve, reject) => {
    // WebP reduces size while the quality value remains skill-controlled.
    canvas.toBlob(
      (result) => result ? resolve(result) : reject(new Error("Image encoding failed.")),
      "image/webp",
      quality,
    );
  });

  return new File([blob], "nextwork-visual-sheet.webp", { type: blob.type });
}
  • Import the visual helpers into App.tsx.
  • Add an image input accepting .png,.jpg,.jpeg,.webp.
  • Add a Create visual sheet button that uses the selected skill's visual settings.
  • Create a temporary download link for the returned nextwork-visual-sheet.webp file.
  • Add a Visual Sheet section to the prompt with the sheet filename and included source paths.
  • Add these visual imports near the top of App.tsx.
import {
  createVisualSheet,
  imagesFromFiles,
  imagesFromZip,
  type VisualInput,
} from "../../utils/visual";
  • Add the visual state below the skill state declarations:
  const [visualInputs, setVisualInputs] = useState<VisualInput[]>([]);
  const [visualManifest, setVisualManifest] = useState("");
  • Add this image-folder picker inside App before loadVisualZip().
  function chooseVisualFolder() {
    // Ask for one folder only after the learner clicks the control.
    const input = document.createElement("input");
    input.type = "file";
    input.multiple = true;
    input.setAttribute("webkitdirectory", "");
    input.addEventListener("change", () => {
      if (!input.files) return;
      const images = imagesFromFiles(input.files);
      setVisualInputs(images);
      setStatus(`Loaded ${images.length} image(s) from a folder.`);
    });
    input.click();
  }
  • Add this ZIP image handler inside App before the returned JSX:
  async function loadVisualZip(file: File | undefined) {
    if (!file) return;

    try {
      // Extract only supported image entries from the approved archive.
      const images = await imagesFromZip(file);
      setVisualInputs(images);
      setStatus(`Loaded ${images.length} image(s) from ${file.name}.`);
    } catch (error) {
      setStatus(errorMessage(error));
    }
  }
  • Add the sheet builder below loadVisualZip().
  async function downloadVisualSheet() {
    try {
      // Apply the selected skill's limits to the visual handoff.
      const settings = selectedSkill.visual;
      const sheet = await createVisualSheet(
        visualInputs, settings.maxImages, settings.columns, settings.quality,
      );
      const url = URL.createObjectURL(sheet);
      const link = document.createElement("a");
      link.href = url;
      link.download = sheet.name;
      link.click();
      URL.revokeObjectURL(url);

      setVisualManifest([
        `Attach manually: ${sheet.name}`,
        "Included images:",
        ...visualInputs.slice(0, settings.maxImages).map((item) => `- ${item.path}`),
      ].join("\n"));
      setStatus(`Created ${sheet.name}. Review it before attaching it.`);
    } catch (error) {
      setStatus(errorMessage(error));
    }
  }
  • Add this visual card above the queue card in the returned JSX:
  • Place this folder button directly below the Visual sheet title in the card:
            <button
              type="button"
              className={secondaryButtonClass}
              onClick={chooseVisualFolder}
            >
              Choose image folder
            </button>
        <Card>
          <CardHeader><CardTitle>Visual sheet</CardTitle></CardHeader>
          <CardContent className="grid gap-3">
            <input
              type="file"
              accept=".png,.jpg,.jpeg,.webp,image/png,image/jpeg,image/webp"
              multiple
              onChange={(event) => {
                if (event.currentTarget.files) {
                  setVisualInputs(imagesFromFiles(event.currentTarget.files));
                }
              }}
            />
            <label className="grid gap-1 text-xs font-medium">
              Import images from ZIP
              <input
                type="file"
                accept=".zip,application/zip"
                onChange={(event) => void loadVisualZip(event.currentTarget.files?.[0])}
              />
            </label>
            <button type="button" className={secondaryButtonClass}
              onClick={() => void downloadVisualSheet()}>
              Create visual sheet
            </button>
            <textarea className={`${fieldClass} min-h-24 font-mono text-xs`}
              readOnly value={visualManifest} placeholder="Visual manifest will appear here." />
          </CardContent>
        </Card>
  • In utils/prompt.ts, add visual?: string; directly below skill?: string;.
  • Add this Visual Sheet section before the source text is appended:
  addSection("Visual Sheet", fields.visual ?? "");
  • In the buildPrompt() call in App.tsx, add this field below skill.
    visual: visualManifest,
  • Save utils/prompt.ts.
  • Save entrypoints/sidepanel/App.tsx.
  • Return to the sidebar and confirm the Visual sheet card appears above the queue.

Does the visual card stay empty?

  • Confirm at least one supported image is selected.
  • Check that selectedSkill.visual contains numeric limits.
  • Confirm visualManifest is declared before the buildPrompt() call.

Help me trace the visual-sheet workflow.

📣 Keep visual attachment manual

NextWork does not publish a stable chat-attachment API for this extension to call. The bridge prepares and downloads the sheet, but you remain responsible for attaching it through the current chat interface.

This keeps the handoff reviewable and avoids private selectors that could change without notice.

Before you create the sheet, do you expect six separate screenshots or one labelled image file?

  • Select two or more non-sensitive screenshots in the visual input.
  • Click Create visual sheet.
  • Open the downloaded nextwork-visual-sheet.webp file.
  • Confirm that every included image has a readable filename label.
  • Arm the current text chunk for the NextWork page.
  • Attach the generated sheet manually using the attachment control available in your NextWork chat.
  • Review the prompt and visual attachment before submitting them.

You should see one compressed visual sheet beside a prompt whose Skill and Visual Sheet sections describe how NextWork should use it.

Is the visual sheet missing or blank?

  • Use PNG, JPEG, or WebP source images.
  • Reduce the selected image count if the canvas exceeds your browser's available memory.
  • Check that the selected skill keeps quality between 0.4 and 0.9.

Need help with the renderer? Help me diagnose my visual sheet.

Your bridge now carries project structure, reusable instructions, reviewed text chunks, and a compressed visual summary. The optional challenge tests whether that richer context still preserves disagreement and uncertainty.

Secret mission

Pressure-Test the Prompt With Conflicting Sources

Two short local files point to different decisions. Use your bridge to preserve the disagreement, request an evidence-aware review, and reject any answer that hides uncertainty.

Clean Up Your Resources

Clean Up Your Resources

Everything you built runs on your Mac. Decide whether to keep it running, pause it for later, or delete it entirely with no ongoing cloud costs.

Resources you used:

  • The nextwork-context-bridge folder on your Desktop. This includes the project source, skill files, and local node_modules dependency tree.
  • The WXT development process running from the Visual Studio Code terminal.
  • The temporary Firefox development window containing the extension.
  • The selected folder, ZIP, skill, and image contents held in the sidebar's in-memory queues.
  • Any downloaded nextwork-visual-sheet.webp file saved by the visual-sheet workflow.

Keep everything running

No action is needed. Choose this while you are still developing or testing the bridge.

  • Leave the development process running in the Visual Studio Code terminal.
  • Keep the WXT Firefox window open so the in-memory bundle, skill, and visual queues remain available.
  • Retain the nextwork-context-bridge folder and any downloaded visual sheets on your Mac.

Pause - I'll come back to this later

Shut down running processes to free local memory while preserving your files. The sidebar queues clear when the Firefox window closes.

  • Return to the Visual Studio Code terminal.
  • Press Control+C to stop the WXT development process.
  • Close the WXT Firefox development window.
  • Keep the nextwork-context-bridge folder and downloaded visual sheets for your next session.

Delete - I don't want to use this again

Remove all project resources and start fresh. Deleting these local files leaves your Firefox installation and NextWork account intact.

  • Return to the Visual Studio Code terminal.
  • Press Control+C to stop the WXT development process.
  • Close the WXT Firefox development window to clear every in-memory queue.
  • Open Finder from the Dock.
  • Select Desktop in the Finder sidebar.
  • Select the nextwork-context-bridge folder.
  • Drag the selected folder to the Trash.
  • Open Downloads in the Finder sidebar.
  • Select every generated nextwork-visual-sheet.webp file.
  • Drag the selected visual sheets to the Trash.
  • Confirm that the Desktop no longer lists nextwork-context-bridge.
  • Confirm that Downloads no longer lists a generated visual sheet.

That clears the running extension, its in-memory data, the project files, and the generated visual artefacts.

Nice Work!

Nice Work!

You made it. Your Firefox extension now turns project folders, ZIP archives, reusable skills, and screenshots into a reviewable prompt package for NextWork.

You've learned how to:

  • Built a typed React side panel that assembles Goal, Context, Output, and Boundaries into a stable structured prompt.
  • Preserved project structure across folder and ZIP imports. Applied reusable JSON skills that control instructions, path priorities, output shape, and image settings.
  • Created a review-first multimodal handoff with deterministic text chunks, a compressed WebP visual sheet, manual attachment, and a clipboard fallback.
  • Completed the optional Secret Mission by preserving conflicting evidence, separating evidence from inference, stating uncertainty, and recommending a next verification action.

Ready to quiz yourself?