feat(ai-chat): cap read_app_file + search_app grep tool to bound context in large raw apps (#9653)

* docs: add global AI chat context-optimization plan for raw apps

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(ai-evals): add global raw-app debugging cases on a large fixture

Adds a ~20-file analytics_dashboard raw-app fixture (incl. a 5k-line data module
and a planted wrong-totals bug), two global cases (read-heavy debug + small-edit
baseline), app-seed support in the mock backend, directory-fixture loading, and a
decorateHelpers seam so read-dedupe is measurable. Records tokenUsage for before/
after comparison of the read-tool optimization.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(ai-chat): cap and dedupe read_app_file to bound context in large apps

read_app_file now defaults to a head slice (1500 lines / 50k chars) with offset/
limit to page further, and skips resending a file whose earlier read is still in
context (per-conversation ledger keyed off the originating tool-call id, so it
self-heals after compaction). Bounds the file-content portion of global-chat
context when working in large raw apps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(ai-evals): add read-heavy raw-app debug case (large data module)

global-test31 induces the model to inspect the 5k-line seedData module, exercising
the read_app_file cap/offset path. Baseline ~262k tokens vs ~200k with the cap+dedupe
change (-24%).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: record A+B benchmark results and fixed-overhead finding

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ai-chat): clearer read_app_file past-EOF message + unit tests for cap/dedupe

Addresses local-review nits: out-of-range offset now reports 'offset N is past the
end of the file' instead of a backwards 'lines 11-10' label; adds unit coverage for
the slicing (line cap, offset/limit window, char budget, past-EOF) and re-read dedupe
(hit + miss-when-not-retained).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(ai-chat): char-level paging + per-range dedupe for read_app_file

Adds char_offset/char_limit so minified/long-line files can be paged within a line
window, keys the re-read ledger by range (so reading different ranges no longer
collides), and dedupes on the full-file hash (a cached range stub is invalidated
when any byte of the file changes, not just the returned range). Tests updated for
the char-slice behavior plus single-line capping, char paging, and out-of-window
change detection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(ai-chat): add read_app_file context micro-benchmark + re-read eval case

Adds a deterministic micro-benchmark (no LLM) that drives read_app_file through a
realistic big-project read pattern (large file, re-read, minified bundle, paging)
and asserts the cap+dedupe cut returned context >50% vs the old whole-file behavior
— isolating the feature's effect from model nondeterminism and guarding against
silent weakening. Adds global-test32, a cross-file consistency investigation that
revisits overlapping files so re-read dedupe is exercised in a real run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(ai-evals): clarify test32 measures the read cap, not dedupe

Verified: sonnet and haiku both read each file once per conversation and retain
it, so test32 never triggers read_app_file re-read dedupe. Dedupe is measured
deterministically by the micro-benchmark instead. Comment corrected to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(ai-chat): drop read_app_file re-read dedupe, ship the cap only

Benchmarking showed the per-conversation re-read dedupe never fires in practice:
across sonnet/opus/gpt-5.5/haiku, every model reads each file once per conversation
and keeps it in context (0 within-conversation re-reads). It was a correct but unused
guard, so this removes the ledger, full-file hash, retention predicate, the
AIChatManager wiring, and the eval decorateHelpers seam — keeping the read cap +
offset/limit/char paging (A), which is the lever that actually bounds context. The
micro-benchmark is now cap-only; test32 is kept as a multi-file read-load case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(ai-chat): add search_app grep tool for global raw-app chat (experimental)

Client-side grep over a raw app's frontend files and inline runnables (literal,
case-insensitive, optional file_glob/context_lines/max_matches, head-capped).
Completes the list -> search -> ranged-read triad. Includes the eval A/B gate
(WMILL_AI_EVAL_DISABLE_SEARCH_APP), unit tests + micro-benchmark, and a
find-all-usages eval case (global-test33).

Experimental: A/B benchmarking shows it is not an unconditional win — it helps
on find-all-usages but adds agentic iterations on navigable apps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(ai-evals): accept search_app as a valid file-inspection tool in raw-app cases

Add requiredToolsAnyOf alternatives-group to ToolValidationSpec and switch
global-test29..32 to it so a model that locates files via search_app instead
of read_app_file no longer false-fails the tool assertion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: remove stale ai-chat context-optimization planning doc

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(ai-chat): drop read_app_file char paging for a hard char cap

The char_offset/char_limit params guarded minified files (a single line over
the char budget) but were effectively unused in benchmarks. Remove them and the
in-window char paging; keep the hard 50k-char budget and, when a read hits it,
tell the model to narrow the line limit (or treat the file as unreadable if a
single line exceeds the budget). Proper long-line handling is left as a TODO.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(ai-chat): bake search_app context to 1 line, clarify query is literal

Drop the context_lines param (models varied it to little effect) for a fixed
SEARCH_APP_CONTEXT_LINES=1, and cap on matching lines instead of pushed rows so
max_matches stays accurate with context always on. Sharpen the query description
to state it is a literal (non-regex) substring and to suggest the call form
(e.g. formatCurrency() to hit call sites and skip formatCurrencyPrecise.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(ai-chat): widen baked search_app context to 2 lines

Models that set the old context_lines param leaned to 2; match the lean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ai-chat): count every file with a match in search_app header

Move fileHadMatch ahead of the render cap so files whose matches fall past max_matches are still counted (with a regression test). Also swap the raw NUL globstar sentinel for a printable escape (the NUL bytes made core.ts read as binary to grep) and reword two comments to describe current constraints instead of drafting history.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ai-chat): drop redundant input echoes from app tool results

read_app_file and search_app no longer prefix results with the tool name or echo back the caller's own inputs (file path, query, file_glob) — the model already has them from the call args, and the unbounded query echo could push the search result past its output budget. Keeps the useful signals (line range, match/file counts, truncation) and the actionable advice. Also reword max_matches to 'matching lines' since it caps lines (each expands to context rows). Unit tests updated to the new format.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
centdix
2026-06-19 15:33:54 +02:00
committed by GitHub
parent 924f9c7e8d
commit 4296a6ae1f
36 changed files with 7450 additions and 34 deletions
@@ -32,6 +32,10 @@ const MUTATING_GLOBAL_TOOLS = new Set([
]);
const DISABLE_ACTIVE_EDITOR_CONTEXT_ENV =
"WMILL_AI_EVAL_DISABLE_ACTIVE_EDITOR_CONTEXT";
// A/B gate for the search_app read tool: set to "1" to run the baseline arm
// (toolset without search_app) so its token cost can be compared against the arm
// that offers it.
const DISABLE_SEARCH_APP_ENV = "WMILL_AI_EVAL_DISABLE_SEARCH_APP";
const LIVE_EDITOR_ITEM_KINDS = {
script: "script",
@@ -193,25 +197,28 @@ function clearLiveEditorDrafts(
}
function getGlobalEvalTools(): ProductionTool<{}>[] {
return (globalTools as ProductionTool<{}>[]).map((tool) => {
if (!MUTATING_GLOBAL_TOOLS.has(tool.def.function.name)) {
return tool;
}
const disableSearchApp = process.env[DISABLE_SEARCH_APP_ENV] === "1";
return (globalTools as ProductionTool<{}>[])
.filter((tool) => !(disableSearchApp && tool.def.function.name === "search_app"))
.map((tool) => {
if (!MUTATING_GLOBAL_TOOLS.has(tool.def.function.name)) {
return tool;
}
return {
...tool,
requiresConfirmation: false,
validateBeforeConfirmation: undefined,
fn: async () =>
JSON.stringify(
{
success: false,
error:
"This mutating workspace tool is disabled during ai_evals global mode.",
},
null,
2,
),
};
});
return {
...tool,
requiresConfirmation: false,
validateBeforeConfirmation: undefined,
fn: async () =>
JSON.stringify(
{
success: false,
error:
"This mutating workspace tool is disabled during ai_evals global mode.",
},
null,
2,
),
};
});
}
+69 -1
View File
@@ -1,5 +1,12 @@
import { randomUUID } from 'node:crypto'
import type { CompletedJob, Flow, Job, Script } from '../../../frontend/src/lib/gen'
import type {
AppWithLastVersion,
CompletedJob,
Flow,
Job,
ListableApp,
Script
} from '../../../frontend/src/lib/gen'
import type {
DataTableTables,
DataTableTableSchema,
@@ -33,6 +40,18 @@ export interface BenchmarkWorkspaceFlow {
value: Flow['value']
}
export interface BenchmarkWorkspaceApp {
path: string
summary: string
value: {
files: Record<string, string>
runnables: Record<string, unknown>
data?: unknown
policy?: unknown
custom_path?: unknown
}
}
export interface BenchmarkWorkspaceJob {
/** Stable id so a case prompt can reference a specific run (e.g. for get_job_logs). */
id?: string
@@ -47,6 +66,7 @@ export interface BenchmarkWorkspaceJob {
export interface BenchmarkWorkspaceRunnables {
scripts?: BenchmarkWorkspaceScript[]
flows?: BenchmarkWorkspaceFlow[]
apps?: BenchmarkWorkspaceApp[]
datatables?: BenchmarkDatatableSeed[]
jobs?: BenchmarkWorkspaceJob[]
}
@@ -161,6 +181,22 @@ export function getBenchmarkFlowByPath(workspace: string, path: string): Flow |
return flow ? buildBenchmarkFlow(flow) : null
}
export function listBenchmarkApps(workspace: string): ListableApp[] | null {
const runnables = benchmarkWorkspaceRunnables.get(workspace)
if (!runnables) {
return null
}
return (runnables.apps ?? []).map(buildBenchmarkListableApp)
}
export function getBenchmarkAppByPath(workspace: string, path: string): AppWithLastVersion | null {
const app = benchmarkWorkspaceRunnables
.get(workspace)
?.apps?.find((entry) => entry.path === path)
return app ? buildBenchmarkApp(app) : null
}
export function createBenchmarkCompletedJob(input: {
workspace: string
jobKind: CompletedJob['job_kind']
@@ -604,3 +640,35 @@ function buildBenchmarkFlow(flow: BenchmarkWorkspaceFlow): Flow {
extra_perms: {}
} as Flow
}
function buildBenchmarkListableApp(app: BenchmarkWorkspaceApp): ListableApp {
return {
id: 0,
workspace_id: 'benchmark',
path: app.path,
summary: app.summary,
version: 1,
extra_perms: {},
edited_at: BENCHMARK_TIMESTAMP,
execution_mode: 'viewer',
raw_app: true
}
}
function buildBenchmarkApp(app: BenchmarkWorkspaceApp): AppWithLastVersion {
return {
id: 0,
workspace_id: 'benchmark',
path: app.path,
summary: app.summary,
versions: [1],
created_by: 'benchmark',
created_at: BENCHMARK_TIMESTAMP,
value: app.value,
policy: (app.value.policy ?? {}) as AppWithLastVersion['policy'],
execution_mode: 'viewer',
extra_perms: {},
custom_path: app.value.custom_path as string | undefined,
raw_app: true
}
}
@@ -33,6 +33,7 @@ vi.mock('$lib/components/vscode', () => ({}))
vi.mock('$lib/gen', async () => {
const actual = await vi.importActual<any>('$lib/gen')
const {
getBenchmarkAppByPath,
getBenchmarkCompletedJob,
getBenchmarkCompletedJobResultMaybe,
getBenchmarkDatatableSchema,
@@ -42,6 +43,7 @@ vi.mock('$lib/gen', async () => {
getBenchmarkScriptByHash,
getBenchmarkScriptByPath,
hasBenchmarkWorkspace,
listBenchmarkApps,
listBenchmarkDatatables,
listBenchmarkDrafts,
listBenchmarkFlows,
@@ -299,12 +301,20 @@ vi.mock('$lib/gen', async () => {
}),
AppService: wrapService(actual.AppService, {
existsApp: async (data: { workspace: string; path: string }) =>
hasBenchmarkWorkspace(data.workspace) ? false : actual.AppService.existsApp(data),
hasBenchmarkWorkspace(data.workspace)
? Boolean(getBenchmarkAppByPath(data.workspace, data.path))
: actual.AppService.existsApp(data),
listApps: async (data: { workspace: string }) =>
hasBenchmarkWorkspace(data.workspace) ? [] : actual.AppService.listApps(data),
hasBenchmarkWorkspace(data.workspace)
? (listBenchmarkApps(data.workspace) ?? [])
: actual.AppService.listApps(data),
getAppByPath: async (data: { workspace: string; path: string }) => {
if (hasBenchmarkWorkspace(data.workspace)) {
throw new Error(`App "${data.path}" not found in benchmark workspace`)
const app = getBenchmarkAppByPath(data.workspace, data.path)
if (!app) {
throw new Error(`App "${data.path}" not found in benchmark workspace`)
}
return app
}
return actual.AppService.getAppByPath(data)
}
+170
View File
@@ -943,3 +943,173 @@
- deploy_workspace_item
- delete_workspace_item
skipJudge: true
# --- Raw app on a large project (context-usage benchmark) ---
# These cases run against the deliberately large `analytics_dashboard` raw-app
# fixture (~20 frontend files incl. a 5k-line data module, plus backend runnables).
# They exist to measure how much context the global chat consumes when working in a
# big raw app: test29 is a read-heavy debugging hunt, test30 is a small edit baseline.
# tokenUsage is recorded per run, so the same cases re-run after a read-tool change
# (the read_app_file cap + offset/limit paging) quantify the optimization. skipJudge:
# the judge only sees the drafts artifact and cannot run the app, so we validate
# deterministically.
- id: global-test29-raw-app-debug-large
prompt: |-
The analytics dashboard app at `f/evals/global/analytics_dashboard` has a bug:
the Revenue Summary tile shows a total that is lower than the per-order line
totals and the per-region breakdown. Track down what is computing revenue
incorrectly and fix it. Keep the change as an AI draft only; do not deploy or
save it.
initial: ai_evals/fixtures/frontend/global/initial/analytics_dashboard
runtime:
maxTurns: 20
validate:
draftCountExactly: 1
requiredDrafts:
- type: app
path: f/evals/global/analytics_dashboard
valueIncludes:
- "return order.unitPrice * order.quantity"
toolExpect:
requiredToolsAnyOf:
# Inspecting the app's files is satisfied by either reading them directly
# or grepping for the revenue calculation.
- [read_app_file, search_app]
forbiddenToolsUsed:
- deploy_workspace_item
- delete_workspace_item
skipJudge: true
judgeChecklist:
- inspects the dashboard app's files to locate the revenue calculation
- fixes the per-order revenue so it multiplies unit price by quantity
- leaves the result as an AI draft and does not deploy or save it
- id: global-test30-raw-app-small-edit-large
prompt: |-
In the dashboard app at `f/evals/global/analytics_dashboard`, change the main
page heading from "Operations Console" to "Revenue Overview". Leave everything
else unchanged. Keep it as an AI draft only; do not deploy or save it.
initial: ai_evals/fixtures/frontend/global/initial/analytics_dashboard
runtime:
maxTurns: 10
validate:
draftCountExactly: 1
requiredDrafts:
- type: app
path: f/evals/global/analytics_dashboard
valueIncludes:
- "Revenue Overview"
toolExpect:
requiredToolsAnyOf:
# Inspecting the app's files is satisfied by reading them directly or
# grepping for the target with search_app.
- [read_app_file, search_app]
forbiddenToolsUsed:
- deploy_workspace_item
- delete_workspace_item
skipJudge: true
judgeChecklist:
- renames the main page heading to Revenue Overview
- does not change other dashboard behavior
- leaves the result as an AI draft only
- id: global-test31-raw-app-debug-inspect-data
prompt: |-
The raw app dashboard at `f/evals/global/analytics_dashboard` is reporting
revenue totals that look too low. Inspect the app's files — both the sample
order data module and the revenue calculation — to work out whether the bug is
in the data or in the calculation, then fix the actual cause. Keep the change as
an AI draft only; do not deploy or save it.
initial: ai_evals/fixtures/frontend/global/initial/analytics_dashboard
runtime:
maxTurns: 22
validate:
draftCountExactly: 1
requiredDrafts:
- type: app
path: f/evals/global/analytics_dashboard
valueIncludes:
- "return order.unitPrice * order.quantity"
toolExpect:
requiredToolsAnyOf:
# Inspecting the app's files is satisfied by reading them directly or
# grepping for the target with search_app.
- [read_app_file, search_app]
forbiddenToolsUsed:
- deploy_workspace_item
- delete_workspace_item
skipJudge: true
judgeChecklist:
- inspects both the sample order data module and the revenue aggregation logic
- identifies the per-order revenue bug and fixes it to multiply unit price by quantity
- leaves the result as an AI draft only
- id: global-test32-raw-app-cross-file-consistency
prompt: |-
The raw app dashboard at `f/evals/global/analytics_dashboard` shows revenue
totals that disagree between the Revenue Summary tile, the orders table, and the
regional breakdown. Investigate how each of those computes revenue, work out
which calculation is wrong, and fix it. Keep the change as an AI draft only; do
not deploy or save it.
# Cross-file investigation: forces the model through several overlapping files
# (the summary's aggregation helper, the orders table, the regional breakdown) —
# a realistic multi-file read load that exercises the read_app_file cap.
initial: ai_evals/fixtures/frontend/global/initial/analytics_dashboard
runtime:
maxTurns: 24
validate:
draftCountExactly: 1
requiredDrafts:
- type: app
path: f/evals/global/analytics_dashboard
valueIncludes:
- "return order.unitPrice * order.quantity"
toolExpect:
requiredToolsAnyOf:
# Inspecting the app's files is satisfied by reading them directly or
# grepping for the target with search_app.
- [read_app_file, search_app]
forbiddenToolsUsed:
- deploy_workspace_item
- delete_workspace_item
skipJudge: true
judgeChecklist:
- inspects the revenue calculation behind the summary tile, the orders table, and the regional breakdown
- identifies that the per-order revenue helper omits quantity and fixes it to multiply unit price by quantity
- leaves the result as an AI draft only
- id: global-test33-raw-app-rename-across-files
prompt: |-
In the dashboard app at `f/evals/global/analytics_dashboard`, rename the
`formatCurrency` helper to `formatMoney` everywhere it is defined, imported, and
called. Leave the separate `formatCurrencyPrecise` helper exactly as it is. Keep
the change as an AI draft only; do not deploy or save it.
# Find-all-usages rename: formatCurrency is defined once and called in 6 places
# spread across 4 component files (and imported in 4). Locating every usage is the
# exact task search_app is meant to make cheap — one grep returns all file:line
# rows instead of reading each component whole. valueExcludes "formatCurrency("
# asserts the definition and all call sites were renamed while tolerating the
# preserved formatCurrencyPrecise (which is never followed by "(").
initial: ai_evals/fixtures/frontend/global/initial/analytics_dashboard
runtime:
maxTurns: 22
validate:
draftCountExactly: 1
requiredDrafts:
- type: app
path: f/evals/global/analytics_dashboard
valueIncludes:
- "export function formatMoney"
- "formatMoney("
valueExcludes:
- "formatCurrency("
toolExpect:
forbiddenToolsUsed:
- deploy_workspace_item
- delete_workspace_item
skipJudge: true
judgeChecklist:
- renames the formatCurrency definition, imports, and all call sites to formatMoney
- leaves the unrelated formatCurrencyPrecise helper unchanged
- leaves the result as an AI draft only
+7
View File
@@ -168,6 +168,13 @@ export interface ToolCallArgumentRule {
export interface ToolValidationSpec {
requiredToolsUsed?: string[];
/**
* Each inner array is an alternatives group: the check passes when at least
* one tool in the group was used. Use when several tools satisfy the same
* intent so a model that picks any valid path passes — e.g. inspecting an
* app's files via either `read_app_file` or `search_app`.
*/
requiredToolsAnyOf?: string[][];
forbiddenToolsUsed?: string[];
toolCallArgs?: ToolCallArgumentRule[];
}
+43
View File
@@ -245,6 +245,49 @@ describe("validateToolExpectations", () => {
'accepted substrings: insert into, update; values: "DROP TABLE orders"',
});
});
it("passes requiredToolsAnyOf when any alternative in the group is used", () => {
const checks = validateToolExpectations({
run: {
success: true,
actual: {},
assistantMessageCount: 1,
toolCallCount: 1,
toolsUsed: ["search_app", "patch_app_file"],
skillsInvoked: [],
},
toolExpect: {
requiredToolsAnyOf: [["read_app_file", "search_app"]],
},
});
expect(checks).toContainEqual({
name: "uses one of read_app_file, search_app",
passed: true,
});
});
it("fails requiredToolsAnyOf when no alternative in the group is used", () => {
const checks = validateToolExpectations({
run: {
success: true,
actual: {},
assistantMessageCount: 1,
toolCallCount: 1,
toolsUsed: ["patch_app_file"],
skillsInvoked: [],
},
toolExpect: {
requiredToolsAnyOf: [["read_app_file", "search_app"]],
},
});
expect(checks).toContainEqual({
name: "uses one of read_app_file, search_app",
passed: false,
details: "tools used: patch_app_file",
});
});
});
describe("validateGlobalState", () => {
+10
View File
@@ -169,6 +169,16 @@ export function validateToolExpectations(input: {
);
}
for (const group of expect.requiredToolsAnyOf ?? []) {
checks.push(
check(
`uses one of ${group.join(", ")}`,
group.some((toolName) => input.run.toolsUsed.includes(toolName)),
`tools used: ${input.run.toolsUsed.join(", ") || "none"}`
)
);
}
for (const toolName of expect.forbiddenToolsUsed ?? []) {
checks.push(
check(
@@ -0,0 +1,68 @@
type OrderStatus = 'paid' | 'shipped' | 'delivered' | 'pending' | 'refunded' | 'cancelled'
interface Order {
id: string
region: string
quantity: number
unitPrice: number
status: OrderStatus
placedAt: string
}
// Server-side revenue rollup. Mirrors the client aggregation but is computed
// from the authoritative mocked order book so it can be used to cross-check
// the dashboard and to back the export.
const orders: Order[] = [
{ id: 'ORD-10001', region: 'North America', quantity: 3, unitPrice: 1195, status: 'delivered', placedAt: '2024-05-02' },
{ id: 'ORD-10002', region: 'EMEA', quantity: 5, unitPrice: 880, status: 'shipped', placedAt: '2024-05-03' },
{ id: 'ORD-10003', region: 'APAC', quantity: 2, unitPrice: 640, status: 'paid', placedAt: '2024-05-05' },
{ id: 'ORD-10004', region: 'LATAM', quantity: 7, unitPrice: 315, status: 'delivered', placedAt: '2024-05-07' },
{ id: 'ORD-10005', region: 'North America', quantity: 4, unitPrice: 150, status: 'refunded', placedAt: '2024-05-09' },
{ id: 'ORD-10006', region: 'EMEA', quantity: 6, unitPrice: 220, status: 'shipped', placedAt: '2024-05-12' },
{ id: 'ORD-10007', region: 'APAC', quantity: 1, unitPrice: 980, status: 'pending', placedAt: '2024-05-15' },
{ id: 'ORD-10008', region: 'North America', quantity: 8, unitPrice: 1100, status: 'delivered', placedAt: '2024-05-18' },
{ id: 'ORD-10009', region: 'EMEA', quantity: 2, unitPrice: 860, status: 'cancelled', placedAt: '2024-05-22' },
{ id: 'ORD-10010', region: 'LATAM', quantity: 9, unitPrice: 290, status: 'paid', placedAt: '2024-05-26' }
]
const REVENUE_STATUSES: OrderStatus[] = ['paid', 'shipped', 'delivered']
export async function main({
from,
to,
region
}: {
from: string
to: string
region: string
}): Promise<{
totalRevenue: number
netRevenue: number
totalOrders: number
averageOrderValue: number
unitsSold: number
refundedRevenue: number
currency: string
}> {
let scoped = orders.filter((order) => order.placedAt >= from && order.placedAt <= to)
if (region && region !== 'all') {
scoped = scoped.filter((order) => order.region === region)
}
const booked = scoped.filter((order) => REVENUE_STATUSES.includes(order.status))
const totalRevenue = booked.reduce((acc, order) => acc + order.unitPrice * order.quantity, 0)
const unitsSold = booked.reduce((acc, order) => acc + order.quantity, 0)
const refundedRevenue = scoped
.filter((order) => order.status === 'refunded')
.reduce((acc, order) => acc + order.unitPrice * order.quantity, 0)
return {
totalRevenue,
netRevenue: totalRevenue - refundedRevenue,
totalOrders: booked.length,
averageOrderValue: booked.length === 0 ? 0 : Math.round(totalRevenue / booked.length),
unitsSold,
refundedRevenue,
currency: 'USD'
}
}
@@ -0,0 +1,4 @@
{
"name": "Compute Summary",
"language": "bun"
}
@@ -0,0 +1,51 @@
// Builds a downloadable report for the current dashboard view. Returns a data
// URL the browser can open directly so the export works without object storage.
export async function main({
from,
to,
region,
format
}: {
from: string
to: string
region: string
format: 'csv' | 'json'
}): Promise<{ url: string; rows: number; filename: string }> {
const summary = {
from,
to,
region: region || 'all',
generatedAt: new Date().toISOString(),
rows: [
{ region: 'North America', revenue: 211_400, orders: 168 },
{ region: 'EMEA', revenue: 142_900, orders: 121 },
{ region: 'APAC', revenue: 86_500, orders: 78 },
{ region: 'LATAM', revenue: 41_500, orders: 45 }
]
}
const scoped =
region && region !== 'all'
? summary.rows.filter((row) => row.region === region)
: summary.rows
let body: string
let mime: string
if (format === 'csv') {
const header = 'region,revenue,orders'
const lines = scoped.map((row) => `${row.region},${row.revenue},${row.orders}`)
body = [header, ...lines].join('\n')
mime = 'text/csv'
} else {
body = JSON.stringify({ ...summary, rows: scoped }, null, 2)
mime = 'application/json'
}
const encoded = Buffer.from(body, 'utf-8').toString('base64')
const filename = `revenue-report-${from}_${to}.${format}`
return {
url: `data:${mime};base64,${encoded}`,
rows: scoped.length,
filename
}
}
@@ -0,0 +1,4 @@
{
"name": "Export Report",
"language": "bun"
}
@@ -0,0 +1,40 @@
interface MetricCardData {
id: string
label: string
value: number
unit: 'currency' | 'count' | 'percent'
delta: number
hint: string
}
// Returns the headline metric cards for the selected range and region. Values
// are mocked but internally consistent (revenue / orders ≈ avg order value).
const baseByRegion: Record<string, { revenue: number; orders: number; units: number; refunds: number }> = {
all: { revenue: 482_300, orders: 412, units: 1840, refunds: 11_900 },
'North America': { revenue: 211_400, orders: 168, units: 770, refunds: 4_200 },
EMEA: { revenue: 142_900, orders: 121, units: 560, refunds: 3_500 },
APAC: { revenue: 86_500, orders: 78, units: 340, refunds: 2_600 },
LATAM: { revenue: 41_500, orders: 45, units: 170, refunds: 1_600 }
}
export async function main({
from,
to,
region
}: {
from: string
to: string
region: string
}): Promise<{ cards: MetricCardData[]; generatedAt: string }> {
const base = baseByRegion[region] ?? baseByRegion.all
const aov = base.orders === 0 ? 0 : Math.round(base.revenue / base.orders)
const cards: MetricCardData[] = [
{ id: 'revenue', label: 'Total Revenue', value: base.revenue, unit: 'currency', delta: 0.082, hint: `Booked revenue ${from} ${to}` },
{ id: 'orders', label: 'Orders', value: base.orders, unit: 'count', delta: 0.041, hint: 'Revenue-bearing orders in range' },
{ id: 'aov', label: 'Avg Order Value', value: aov, unit: 'currency', delta: -0.013, hint: 'Total revenue / order count' },
{ id: 'units', label: 'Units Sold', value: base.units, unit: 'count', delta: 0.067, hint: 'Total units in range' },
{ id: 'refunds', label: 'Refunded', value: base.refunds, unit: 'currency', delta: -0.021, hint: 'Revenue lost to refunds' },
{ id: 'conversion', label: 'Conversion', value: 0.187, unit: 'percent', delta: 0.009, hint: 'Sessions that became orders' }
]
return { cards, generatedAt: new Date().toISOString() }
}
@@ -0,0 +1,4 @@
{
"name": "Load Metrics",
"language": "bun"
}
@@ -0,0 +1,54 @@
type OrderStatus = 'paid' | 'shipped' | 'delivered' | 'pending' | 'refunded' | 'cancelled'
interface Order {
id: string
placedAt: string
customer: string
product: string
sku: string
region: string
channel: string
rep: string
quantity: number
unitPrice: number
status: OrderStatus
}
// Mocked order book. In a real deployment this would query the orders table;
// here it returns a representative slice so the table renders in preview.
const orders: Order[] = [
{ id: 'ORD-10001', placedAt: '2024-05-02T09:14:00Z', customer: 'Contoso Ltd', product: 'Aurora Analytics Suite', sku: 'ANL-100', region: 'North America', channel: 'direct', rep: 'Dana Wills', quantity: 3, unitPrice: 1195, status: 'delivered' },
{ id: 'ORD-10002', placedAt: '2024-05-03T11:42:00Z', customer: 'Fabrikam Inc', product: 'Borealis CRM', sku: 'CRM-210', region: 'EMEA', channel: 'partner', rep: 'Lena Fischer', quantity: 5, unitPrice: 880, status: 'shipped' },
{ id: 'ORD-10003', placedAt: '2024-05-05T15:03:00Z', customer: 'Tailspin Toys', product: 'Cascade Data Pipeline', sku: 'PIPE-330', region: 'APAC', channel: 'self-serve', rep: 'Sora Tanaka', quantity: 2, unitPrice: 640, status: 'paid' },
{ id: 'ORD-10004', placedAt: '2024-05-07T08:21:00Z', customer: 'Proseware Inc', product: 'Delta Insights', sku: 'INS-440', region: 'LATAM', channel: 'marketplace', rep: 'Diego Marin', quantity: 7, unitPrice: 315, status: 'delivered' },
{ id: 'ORD-10005', placedAt: '2024-05-09T13:58:00Z', customer: 'Litware Inc', product: 'Echo Monitoring', sku: 'MON-550', region: 'North America', channel: 'direct', rep: 'Owen Pratt', quantity: 4, unitPrice: 150, status: 'refunded' },
{ id: 'ORD-10006', placedAt: '2024-05-12T10:30:00Z', customer: 'Fourth Coffee', product: 'Helix Identity', sku: 'IDN-880', region: 'EMEA', channel: 'partner', rep: 'Aisha Khan', quantity: 6, unitPrice: 220, status: 'shipped' },
{ id: 'ORD-10007', placedAt: '2024-05-15T17:11:00Z', customer: 'Coho Vineyard', product: 'Kelvin Forecasting', sku: 'FCT-202', region: 'APAC', channel: 'direct', rep: 'Priya Nair', quantity: 1, unitPrice: 980, status: 'pending' },
{ id: 'ORD-10008', placedAt: '2024-05-18T12:05:00Z', customer: 'Alpine Ski House', product: 'Nimbus Compute', sku: 'CMP-505', region: 'North America', channel: 'self-serve', rep: 'Hugo Bernard', quantity: 8, unitPrice: 1100, status: 'delivered' },
{ id: 'ORD-10009', placedAt: '2024-05-22T14:47:00Z', customer: 'Trey Research', product: 'Onyx Security', sku: 'SEC-606', region: 'EMEA', channel: 'direct', rep: 'Sven Olsen', quantity: 2, unitPrice: 860, status: 'cancelled' },
{ id: 'ORD-10010', placedAt: '2024-05-26T16:39:00Z', customer: 'Blue Yonder Airlines', product: 'Polaris Reporting', sku: 'RPT-707', region: 'LATAM', channel: 'partner', rep: 'Mateo Russo', quantity: 9, unitPrice: 290, status: 'paid' }
]
export async function main({
from,
to,
region,
status
}: {
from: string
to: string
region: string
status: string
}): Promise<{ orders: Order[]; total: number }> {
let filtered = orders.filter((order) => {
const day = order.placedAt.slice(0, 10)
return day >= from && day <= to
})
if (region && region !== 'all') {
filtered = filtered.filter((order) => order.region === region)
}
if (status && status !== 'all') {
filtered = filtered.filter((order) => order.status === status)
}
return { orders: filtered, total: filtered.length }
}
@@ -0,0 +1,4 @@
{
"name": "Load Orders",
"language": "bun"
}
@@ -0,0 +1,45 @@
import React from 'react'
import type { DateRange } from '../lib/api'
import { rangeForPreset } from '../lib/api'
import { formatDateShort } from '../lib/format'
interface DateRangePickerProps {
preset: string
range: DateRange
onPresetChange: (preset: string, range: DateRange) => void
}
const PRESETS: { id: string; label: string }[] = [
{ id: '7d', label: 'Last 7 days' },
{ id: '14d', label: 'Last 14 days' },
{ id: '30d', label: 'Last 30 days' },
{ id: 'qtd', label: 'Quarter to date' }
]
export const DateRangePicker: React.FC<DateRangePickerProps> = ({
preset,
range,
onPresetChange
}) => {
return (
<div className="flex items-center gap-2">
<select
className="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700"
value={preset}
onChange={(event) => {
const next = event.target.value
onPresetChange(next, rangeForPreset(next))
}}
>
{PRESETS.map((item) => (
<option key={item.id} value={item.id}>
{item.label}
</option>
))}
</select>
<span className="text-xs text-gray-400">
{formatDateShort(range.from)} {formatDateShort(range.to)}
</span>
</div>
)
}
@@ -0,0 +1,28 @@
import React from 'react'
interface EmptyStateProps {
title: string
description?: string
icon?: string
action?: React.ReactNode
}
export const EmptyState: React.FC<EmptyStateProps> = ({
title,
description,
icon = '📊',
action
}) => {
return (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-gray-300 bg-white py-12 text-center">
<div className="text-3xl" aria-hidden>
{icon}
</div>
<h3 className="mt-3 text-sm font-semibold text-gray-700">{title}</h3>
{description ? (
<p className="mt-1 max-w-sm text-sm text-gray-500">{description}</p>
) : null}
{action ? <div className="mt-4">{action}</div> : null}
</div>
)
}
@@ -0,0 +1,51 @@
import React, { useState } from 'react'
import { requestExport } from '../lib/api'
import type { DateRange } from '../lib/api'
interface ExportButtonProps {
range: DateRange
region: string
}
export const ExportButton: React.FC<ExportButtonProps> = ({ range, region }) => {
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleExport = async (format: 'csv' | 'json') => {
setBusy(true)
setError(null)
try {
const result = await requestExport(range, region, format)
const anchor = document.createElement('a')
anchor.href = result.url
anchor.download = `revenue-report.${format}`
anchor.click()
} catch (err) {
setError(err instanceof Error ? err.message : 'Export failed')
} finally {
setBusy(false)
}
}
return (
<div className="flex items-center gap-2">
<button
type="button"
disabled={busy}
onClick={() => handleExport('csv')}
className="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50"
>
{busy ? 'Exporting…' : 'Export CSV'}
</button>
<button
type="button"
disabled={busy}
onClick={() => handleExport('json')}
className="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50"
>
Export JSON
</button>
{error ? <span className="text-xs text-rose-600">{error}</span> : null}
</div>
)
}
@@ -0,0 +1,59 @@
import React from 'react'
import type { DateRange } from '../lib/api'
import type { OrderStatus } from '../data/seedData'
import { REGIONS, ORDER_STATUSES, STATUS_LABELS } from '../data/seedData'
import { DateRangePicker } from './DateRangePicker'
import { ExportButton } from './ExportButton'
interface FilterBarProps {
region: string
status: string
preset: string
range: DateRange
onRegionChange: (region: string) => void
onStatusChange: (status: string) => void
onPresetChange: (preset: string, range: DateRange) => void
}
export const FilterBar: React.FC<FilterBarProps> = ({
region,
status,
preset,
range,
onRegionChange,
onStatusChange,
onPresetChange
}) => {
return (
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-gray-200 bg-white px-6 py-4">
<div className="flex flex-wrap items-center gap-3">
<DateRangePicker preset={preset} range={range} onPresetChange={onPresetChange} />
<select
className="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700"
value={region}
onChange={(event) => onRegionChange(event.target.value)}
>
<option value="all">All regions</option>
{REGIONS.map((item) => (
<option key={item} value={item}>
{item}
</option>
))}
</select>
<select
className="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700"
value={status}
onChange={(event) => onStatusChange(event.target.value)}
>
<option value="all">All statuses</option>
{ORDER_STATUSES.map((item) => (
<option key={item} value={item}>
{STATUS_LABELS[item as OrderStatus]}
</option>
))}
</select>
</div>
<ExportButton range={range} region={region} />
</div>
)
}
@@ -0,0 +1,40 @@
import React from 'react'
import type { MetricCardData } from '../data/seedData'
import { formatCurrency, formatNumber, formatPercent, formatSignedPercent } from '../lib/format'
interface MetricCardProps {
metric: MetricCardData
loading?: boolean
}
function renderValue(metric: MetricCardData): string {
switch (metric.unit) {
case 'currency':
return formatCurrency(metric.value)
case 'percent':
return formatPercent(metric.value)
case 'count':
default:
return formatNumber(metric.value)
}
}
export const MetricCard: React.FC<MetricCardProps> = ({ metric, loading }) => {
const positive = metric.delta >= 0
return (
<div className="rounded-xl border border-gray-200 bg-white p-5 shadow-sm">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-500">{metric.label}</span>
<span
className={`text-xs font-semibold ${positive ? 'text-emerald-600' : 'text-rose-600'}`}
>
{formatSignedPercent(metric.delta)}
</span>
</div>
<div className="mt-2 text-2xl font-bold text-gray-900">
{loading ? <span className="text-gray-300"></span> : renderValue(metric)}
</div>
<p className="mt-1 text-xs text-gray-400">{metric.hint}</p>
</div>
)
}
@@ -0,0 +1,18 @@
import React from 'react'
import type { MetricCardData } from '../data/seedData'
import { MetricCard } from './MetricCard'
interface MetricGridProps {
metrics: MetricCardData[]
loading?: boolean
}
export const MetricGrid: React.FC<MetricGridProps> = ({ metrics, loading }) => {
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{metrics.map((metric) => (
<MetricCard key={metric.id} metric={metric} loading={loading} />
))}
</div>
)
}
@@ -0,0 +1,117 @@
import React, { useMemo, useState } from 'react'
import type { Order } from '../data/seedData'
import { StatusBadge } from './StatusBadge'
import { EmptyState } from './EmptyState'
import { formatCurrencyPrecise, formatDate, formatNumber, truncate } from '../lib/format'
interface OrdersTableProps {
orders: Order[]
loading?: boolean
}
type SortKey = 'placedAt' | 'customer' | 'lineTotal' | 'quantity'
type SortDir = 'asc' | 'desc'
// The per-row line total a customer was charged: unit price times quantity.
function lineTotal(order: Order): number {
return order.quantity * order.unitPrice
}
export const OrdersTable: React.FC<OrdersTableProps> = ({ orders, loading }) => {
const [sortKey, setSortKey] = useState<SortKey>('placedAt')
const [sortDir, setSortDir] = useState<SortDir>('desc')
const sorted = useMemo(() => {
const copy = [...orders]
copy.sort((a, b) => {
let comparison = 0
switch (sortKey) {
case 'customer':
comparison = a.customer.localeCompare(b.customer)
break
case 'lineTotal':
comparison = lineTotal(a) - lineTotal(b)
break
case 'quantity':
comparison = a.quantity - b.quantity
break
case 'placedAt':
default:
comparison = a.placedAt.localeCompare(b.placedAt)
break
}
return sortDir === 'asc' ? comparison : -comparison
})
return copy
}, [orders, sortKey, sortDir])
const toggleSort = (key: SortKey) => {
if (key === sortKey) {
setSortDir((dir) => (dir === 'asc' ? 'desc' : 'asc'))
} else {
setSortKey(key)
setSortDir('desc')
}
}
if (!loading && orders.length === 0) {
return (
<EmptyState
title="No orders match these filters"
description="Try widening the date range or clearing the status filter."
icon="🗂️"
/>
)
}
const arrow = (key: SortKey) => (key === sortKey ? (sortDir === 'asc' ? '▲' : '▼') : '')
return (
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm">
<table className="min-w-full divide-y divide-gray-200 text-sm">
<thead className="bg-gray-50 text-left text-xs uppercase tracking-wide text-gray-500">
<tr>
<th className="cursor-pointer px-4 py-3" onClick={() => toggleSort('placedAt')}>
Date {arrow('placedAt')}
</th>
<th className="cursor-pointer px-4 py-3" onClick={() => toggleSort('customer')}>
Customer {arrow('customer')}
</th>
<th className="px-4 py-3">Product</th>
<th className="px-4 py-3">Region</th>
<th className="cursor-pointer px-4 py-3 text-right" onClick={() => toggleSort('quantity')}>
Qty {arrow('quantity')}
</th>
<th className="px-4 py-3 text-right">Unit Price</th>
<th className="cursor-pointer px-4 py-3 text-right" onClick={() => toggleSort('lineTotal')}>
Line Total {arrow('lineTotal')}
</th>
<th className="px-4 py-3">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{sorted.map((order) => (
<tr key={order.id} className="hover:bg-gray-50">
<td className="px-4 py-3 text-gray-500">{formatDate(order.placedAt)}</td>
<td className="px-4 py-3 font-medium text-gray-900">
{truncate(order.customer, 24)}
</td>
<td className="px-4 py-3 text-gray-600">{order.product}</td>
<td className="px-4 py-3 text-gray-600">{order.region}</td>
<td className="px-4 py-3 text-right text-gray-600">{formatNumber(order.quantity)}</td>
<td className="px-4 py-3 text-right text-gray-600">
{formatCurrencyPrecise(order.unitPrice)}
</td>
<td className="px-4 py-3 text-right font-semibold text-gray-900">
{formatCurrencyPrecise(lineTotal(order))}
</td>
<td className="px-4 py-3">
<StatusBadge status={order.status} />
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
@@ -0,0 +1,52 @@
import React, { useMemo } from 'react'
import type { Order } from '../data/seedData'
import { breakdownByRegion } from '../lib/aggregations'
import { formatCurrency, formatNumber, formatPercent } from '../lib/format'
import { EmptyState } from './EmptyState'
interface RegionTableProps {
orders: Order[]
}
export const RegionTable: React.FC<RegionTableProps> = ({ orders }) => {
const rows = useMemo(() => breakdownByRegion(orders), [orders])
const total = useMemo(() => rows.reduce((acc, row) => acc + row.revenue, 0), [rows])
if (rows.length === 0) {
return (
<EmptyState
title="No regional revenue"
description="No revenue-bearing orders fall in the current selection."
icon="🌍"
/>
)
}
return (
<section className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
<h2 className="mb-4 text-lg font-semibold text-gray-900">Revenue by Region</h2>
<table className="min-w-full text-sm">
<thead className="text-left text-xs uppercase tracking-wide text-gray-500">
<tr>
<th className="py-2">Region</th>
<th className="py-2 text-right">Orders</th>
<th className="py-2 text-right">Revenue</th>
<th className="py-2 text-right">Share</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{rows.map((row) => (
<tr key={row.region}>
<td className="py-2 font-medium text-gray-900">{row.region}</td>
<td className="py-2 text-right text-gray-600">{formatNumber(row.orders)}</td>
<td className="py-2 text-right text-gray-900">{formatCurrency(row.revenue)}</td>
<td className="py-2 text-right text-gray-500">
{formatPercent(total === 0 ? 0 : row.revenue / total)}
</td>
</tr>
))}
</tbody>
</table>
</section>
)
}
@@ -0,0 +1,49 @@
import React, { useMemo } from 'react'
import type { Order } from '../data/seedData'
import { dailyRevenue } from '../lib/aggregations'
import { formatCompact, formatDateShort } from '../lib/format'
import { EmptyState } from './EmptyState'
interface RevenueChartProps {
orders: Order[]
}
// Lightweight inline bar chart for daily revenue. Avoids a charting dependency
// by sizing flexed columns relative to the busiest day in the window.
export const RevenueChart: React.FC<RevenueChartProps> = ({ orders }) => {
const points = useMemo(() => dailyRevenue(orders), [orders])
const max = useMemo(() => points.reduce((acc, point) => Math.max(acc, point.revenue), 0), [points])
if (points.length === 0) {
return (
<EmptyState
title="No revenue in range"
description="Adjust the date range or filters to see daily revenue."
icon="📉"
/>
)
}
return (
<section className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
<h2 className="mb-4 text-lg font-semibold text-gray-900">Daily Revenue</h2>
<div className="flex h-48 items-end gap-1">
{points.map((point) => {
const heightPct = max === 0 ? 0 : Math.round((point.revenue / max) * 100)
return (
<div key={point.date} className="flex flex-1 flex-col items-center justify-end">
<div
className="w-full rounded-t bg-indigo-400"
style={{ height: `${Math.max(heightPct, 2)}%` }}
title={`${point.date}: ${formatCompact(point.revenue)}`}
/>
<span className="mt-1 truncate text-[9px] text-gray-400">
{formatDateShort(point.date)}
</span>
</div>
)
})}
</div>
</section>
)
}
@@ -0,0 +1,50 @@
import React from 'react'
export type DashboardView = 'overview' | 'orders' | 'regions' | 'products'
interface SidebarProps {
active: DashboardView
onSelect: (view: DashboardView) => void
}
const NAV_ITEMS: { id: DashboardView; label: string; icon: string }[] = [
{ id: 'overview', label: 'Overview', icon: '📈' },
{ id: 'orders', label: 'Orders', icon: '🧾' },
{ id: 'regions', label: 'Regions', icon: '🌍' },
{ id: 'products', label: 'Products', icon: '📦' }
]
export const Sidebar: React.FC<SidebarProps> = ({ active, onSelect }) => {
return (
<aside className="flex w-56 flex-col border-r border-gray-200 bg-white">
<div className="flex items-center gap-2 border-b border-gray-200 px-5 py-4">
<span className="text-xl">🪁</span>
<span className="text-sm font-bold text-gray-900">Acme Operations</span>
</div>
<nav className="flex-1 space-y-1 p-3">
{NAV_ITEMS.map((item) => {
const isActive = item.id === active
return (
<button
key={item.id}
type="button"
onClick={() => onSelect(item.id)}
className={`flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left text-sm font-medium transition ${
isActive
? 'bg-indigo-50 text-indigo-700'
: 'text-gray-600 hover:bg-gray-50'
}`}
>
<span aria-hidden>{item.icon}</span>
{item.label}
</button>
)
})}
</nav>
<div className="border-t border-gray-200 p-4 text-xs text-gray-400">
Analytics workspace
<div className="mt-1 font-mono text-[10px] text-gray-300">v2.4.0</div>
</div>
</aside>
)
}
@@ -0,0 +1,26 @@
import React from 'react'
import type { OrderStatus } from '../data/seedData'
import { STATUS_LABELS } from '../data/seedData'
interface StatusBadgeProps {
status: OrderStatus
}
const STATUS_STYLES: Record<OrderStatus, string> = {
paid: 'bg-blue-100 text-blue-700',
shipped: 'bg-indigo-100 text-indigo-700',
delivered: 'bg-emerald-100 text-emerald-700',
pending: 'bg-amber-100 text-amber-700',
refunded: 'bg-rose-100 text-rose-700',
cancelled: 'bg-gray-200 text-gray-600'
}
export const StatusBadge: React.FC<StatusBadgeProps> = ({ status }) => {
return (
<span
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${STATUS_STYLES[status]}`}
>
{STATUS_LABELS[status]}
</span>
)
}
@@ -0,0 +1,51 @@
import React, { useMemo } from 'react'
import type { Order } from '../data/seedData'
import { summarizeRevenue } from '../lib/aggregations'
import { formatCurrency, formatCurrencyPrecise, formatNumber } from '../lib/format'
interface SummaryPanelProps {
orders: Order[]
loading?: boolean
}
// Headline revenue panel. It re-aggregates the orders client-side via
// summarizeRevenue so the totals stay in sync with whatever filter the user
// has applied, without waiting for another backend round trip.
export const SummaryPanel: React.FC<SummaryPanelProps> = ({ orders, loading }) => {
const summary = useMemo(() => summarizeRevenue(orders), [orders])
const tiles = [
{ label: 'Total Revenue', value: formatCurrency(summary.totalRevenue), emphasis: true },
{ label: 'Net Revenue', value: formatCurrency(summary.netRevenue) },
{ label: 'Orders', value: formatNumber(summary.totalOrders) },
{ label: 'Avg Order Value', value: formatCurrencyPrecise(summary.averageOrderValue) },
{ label: 'Units Sold', value: formatNumber(summary.unitsSold) },
{ label: 'Refunded', value: formatCurrency(summary.refundedRevenue) }
]
return (
<section className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900">Revenue Summary</h2>
{loading ? <span className="text-xs text-gray-400">Refreshing</span> : null}
</div>
<div className="grid grid-cols-2 gap-4 md:grid-cols-3">
{tiles.map((tile) => (
<div
key={tile.label}
className={`rounded-lg p-4 ${tile.emphasis ? 'bg-indigo-50' : 'bg-gray-50'}`}
>
<div className="text-xs font-medium uppercase tracking-wide text-gray-500">
{tile.label}
</div>
<div
className={`mt-1 font-bold ${tile.emphasis ? 'text-2xl text-indigo-700' : 'text-xl text-gray-900'}`}
>
{tile.value}
</div>
</div>
))}
</div>
</section>
)
}
@@ -0,0 +1,55 @@
import React, { useMemo } from 'react'
import type { Order } from '../data/seedData'
import { topProducts } from '../lib/aggregations'
import { formatCurrency } from '../lib/format'
import { EmptyState } from './EmptyState'
interface TopProductsProps {
orders: Order[]
limit?: number
}
export const TopProducts: React.FC<TopProductsProps> = ({ orders, limit = 5 }) => {
const products = useMemo(() => topProducts(orders, limit), [orders, limit])
const max = useMemo(
() => products.reduce((acc, item) => Math.max(acc, item.revenue), 0),
[products]
)
if (products.length === 0) {
return (
<EmptyState
title="No product revenue"
description="No revenue-bearing orders to rank by product."
icon="📦"
/>
)
}
return (
<section className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
<h2 className="mb-4 text-lg font-semibold text-gray-900">Top Products</h2>
<ul className="space-y-3">
{products.map((item, index) => {
const widthPct = max === 0 ? 0 : Math.round((item.revenue / max) * 100)
return (
<li key={item.product}>
<div className="flex items-center justify-between text-sm">
<span className="font-medium text-gray-800">
{index + 1}. {item.product}
</span>
<span className="text-gray-600">{formatCurrency(item.revenue)}</span>
</div>
<div className="mt-1 h-2 w-full overflow-hidden rounded-full bg-gray-100">
<div
className="h-full rounded-full bg-emerald-400"
style={{ width: `${Math.max(widthPct, 2)}%` }}
/>
</div>
</li>
)
})}
</ul>
</section>
)
}
@@ -0,0 +1,164 @@
import React, { useEffect, useMemo, useState } from 'react'
import { Sidebar, type DashboardView } from './components/Sidebar'
import { FilterBar } from './components/FilterBar'
import { MetricGrid } from './components/MetricGrid'
import { SummaryPanel } from './components/SummaryPanel'
import { RevenueChart } from './components/RevenueChart'
import { OrdersTable } from './components/OrdersTable'
import { RegionTable } from './components/RegionTable'
import { TopProducts } from './components/TopProducts'
import { EmptyState } from './components/EmptyState'
import { fetchMetrics, fetchOrders, rangeForPreset, type DateRange } from './lib/api'
import {
seedOrders,
seedMetricCards,
ordersInRange,
ordersForRegion,
type Order,
type MetricCardData
} from './data/seedData'
const App = () => {
const [view, setView] = useState<DashboardView>('overview')
const [preset, setPreset] = useState('30d')
const [range, setRange] = useState<DateRange>(rangeForPreset('30d'))
const [region, setRegion] = useState('all')
const [status, setStatus] = useState('all')
const [metrics, setMetrics] = useState<MetricCardData[]>(seedMetricCards)
const [orders, setOrders] = useState<Order[]>(seedOrders)
const [loadingMetrics, setLoadingMetrics] = useState(true)
const [loadingOrders, setLoadingOrders] = useState(true)
const [errored, setErrored] = useState(false)
useEffect(() => {
let cancelled = false
setLoadingMetrics(true)
fetchMetrics(range, region)
.then((result) => {
if (!cancelled) {
setMetrics(result.cards)
}
})
.catch(() => {
if (!cancelled) {
setMetrics(seedMetricCards)
}
})
.finally(() => {
if (!cancelled) {
setLoadingMetrics(false)
}
})
return () => {
cancelled = true
}
}, [range, region])
useEffect(() => {
let cancelled = false
setLoadingOrders(true)
setErrored(false)
fetchOrders(range, region, status)
.then((result) => {
if (!cancelled) {
setOrders(result.orders)
}
})
.catch(() => {
if (!cancelled) {
// Fall back to the bundled seed data so the dashboard still renders.
const scoped = ordersForRegion(
ordersInRange(seedOrders, range.from, range.to),
region
).filter((order) => status === 'all' || order.status === status)
setOrders(scoped)
setErrored(true)
}
})
.finally(() => {
if (!cancelled) {
setLoadingOrders(false)
}
})
return () => {
cancelled = true
}
}, [range, region, status])
const handlePresetChange = (nextPreset: string, nextRange: DateRange) => {
setPreset(nextPreset)
setRange(nextRange)
}
// Orders that drive the summary/chart panels — the table applies the status
// filter itself, so the panels see the same range/region scoped orders.
const scopedOrders = useMemo(() => orders, [orders])
const renderView = () => {
switch (view) {
case 'orders':
return <OrdersTable orders={scopedOrders} loading={loadingOrders} />
case 'regions':
return <RegionTable orders={scopedOrders} />
case 'products':
return <TopProducts orders={scopedOrders} limit={8} />
case 'overview':
default:
return (
<div className="space-y-6">
<MetricGrid metrics={metrics} loading={loadingMetrics} />
<SummaryPanel orders={scopedOrders} loading={loadingOrders} />
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<RevenueChart orders={scopedOrders} />
<TopProducts orders={scopedOrders} />
</div>
<RegionTable orders={scopedOrders} />
</div>
)
}
}
return (
<div className="flex h-screen bg-gray-100 text-gray-900">
<Sidebar active={view} onSelect={setView} />
<div className="flex flex-1 flex-col overflow-hidden">
<header className="border-b border-gray-200 bg-white px-6 py-5">
<p className="text-xs font-semibold uppercase tracking-wide text-indigo-500">
Acme Inc
</p>
<h1 className="text-2xl font-bold text-gray-900">Operations Console</h1>
<p className="mt-1 text-sm text-gray-500">
Revenue, orders, and regional performance at a glance.
</p>
</header>
<FilterBar
region={region}
status={status}
preset={preset}
range={range}
onRegionChange={setRegion}
onStatusChange={setStatus}
onPresetChange={handlePresetChange}
/>
<main className="flex-1 overflow-auto p-6">
{errored ? (
<div className="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-2 text-sm text-amber-700">
Showing locally bundled data the live feed is unavailable.
</div>
) : null}
{scopedOrders.length === 0 && !loadingOrders ? (
<EmptyState
title="Nothing to show yet"
description="No data for the selected range, region, and status."
/>
) : (
renderView()
)}
</main>
</div>
</div>
)
}
export default App
@@ -0,0 +1,149 @@
// Aggregation helpers that turn raw order/metric rows into the numbers the
// dashboard renders. These run client-side after the backend returns rows so
// the UI can re-aggregate instantly when filters change without a round trip.
import type { Order, OrderStatus } from '../data/seedData'
export interface RevenueSummary {
totalRevenue: number
totalOrders: number
averageOrderValue: number
unitsSold: number
refundedRevenue: number
netRevenue: number
}
export interface StatusBreakdown {
status: OrderStatus
orders: number
revenue: number
}
export interface RegionBreakdown {
region: string
orders: number
revenue: number
}
export interface DailyPoint {
date: string
revenue: number
orders: number
}
// Revenue for a single line item. An order's revenue is the unit price times
// the number of units purchased — never the unit price alone.
export function orderRevenue(order: Order): number {
return order.unitPrice
}
// The statuses that count toward realized (booked) revenue. Refunded and
// cancelled orders are excluded from the headline revenue total.
const REVENUE_STATUSES: OrderStatus[] = ['paid', 'shipped', 'delivered']
export function isRevenueStatus(status: OrderStatus): boolean {
return REVENUE_STATUSES.includes(status)
}
export function sumRevenue(orders: Order[]): number {
return orders
.filter((order) => isRevenueStatus(order.status))
.reduce((acc, order) => acc + orderRevenue(order), 0)
}
export function sumUnits(orders: Order[]): number {
return orders
.filter((order) => isRevenueStatus(order.status))
.reduce((acc, order) => acc + order.quantity, 0)
}
export function sumRefundedRevenue(orders: Order[]): number {
return orders
.filter((order) => order.status === 'refunded')
.reduce((acc, order) => acc + order.unitPrice * order.quantity, 0)
}
export function summarizeRevenue(orders: Order[]): RevenueSummary {
const revenueOrders = orders.filter((order) => isRevenueStatus(order.status))
const totalRevenue = sumRevenue(orders)
const unitsSold = sumUnits(orders)
const refundedRevenue = sumRefundedRevenue(orders)
const totalOrders = revenueOrders.length
return {
totalRevenue,
totalOrders,
averageOrderValue: totalOrders === 0 ? 0 : totalRevenue / totalOrders,
unitsSold,
refundedRevenue,
netRevenue: totalRevenue - refundedRevenue
}
}
export function breakdownByStatus(orders: Order[]): StatusBreakdown[] {
const map = new Map<OrderStatus, StatusBreakdown>()
for (const order of orders) {
const existing = map.get(order.status) ?? {
status: order.status,
orders: 0,
revenue: 0
}
existing.orders += 1
existing.revenue += order.unitPrice * order.quantity
map.set(order.status, existing)
}
return [...map.values()].sort((a, b) => b.revenue - a.revenue)
}
export function breakdownByRegion(orders: Order[]): RegionBreakdown[] {
const map = new Map<string, RegionBreakdown>()
for (const order of orders) {
if (!isRevenueStatus(order.status)) {
continue
}
const existing = map.get(order.region) ?? {
region: order.region,
orders: 0,
revenue: 0
}
existing.orders += 1
existing.revenue += order.unitPrice * order.quantity
map.set(order.region, existing)
}
return [...map.values()].sort((a, b) => b.revenue - a.revenue)
}
export function dailyRevenue(orders: Order[]): DailyPoint[] {
const map = new Map<string, DailyPoint>()
for (const order of orders) {
if (!isRevenueStatus(order.status)) {
continue
}
const day = order.placedAt.slice(0, 10)
const existing = map.get(day) ?? { date: day, revenue: 0, orders: 0 }
existing.revenue += order.unitPrice * order.quantity
existing.orders += 1
map.set(day, existing)
}
return [...map.values()].sort((a, b) => a.date.localeCompare(b.date))
}
export function topProducts(orders: Order[], limit: number = 5): { product: string; revenue: number }[] {
const map = new Map<string, number>()
for (const order of orders) {
if (!isRevenueStatus(order.status)) {
continue
}
map.set(order.product, (map.get(order.product) ?? 0) + order.unitPrice * order.quantity)
}
return [...map.entries()]
.map(([product, revenue]) => ({ product, revenue }))
.sort((a, b) => b.revenue - a.revenue)
.slice(0, limit)
}
export function growthRatio(current: number, previous: number): number {
if (previous === 0) {
return current === 0 ? 0 : 1
}
return (current - previous) / previous
}
@@ -0,0 +1,79 @@
// Thin wrappers around the app's backend runnables. Centralizing the calls
// here keeps the components free of `backend.*` plumbing and gives one place to
// normalize the request/response shapes.
import { backend } from 'wmill'
import type { Order, MetricCardData } from '../data/seedData'
export interface DateRange {
from: string
to: string
}
export interface MetricsResponse {
cards: MetricCardData[]
generatedAt: string
}
export interface OrdersResponse {
orders: Order[]
total: number
}
export interface SummaryResponse {
totalRevenue: number
netRevenue: number
totalOrders: number
averageOrderValue: number
unitsSold: number
refundedRevenue: number
currency: string
}
export async function fetchMetrics(range: DateRange, region: string): Promise<MetricsResponse> {
return backend.loadMetrics({ from: range.from, to: range.to, region })
}
export async function fetchOrders(
range: DateRange,
region: string,
status: string
): Promise<OrdersResponse> {
return backend.loadOrders({
from: range.from,
to: range.to,
region,
status
})
}
export async function fetchSummary(range: DateRange, region: string): Promise<SummaryResponse> {
return backend.computeSummary({ from: range.from, to: range.to, region })
}
export async function requestExport(
range: DateRange,
region: string,
format: 'csv' | 'json'
): Promise<{ url: string; rows: number }> {
return backend.exportReport({ from: range.from, to: range.to, region, format })
}
export function defaultRange(): DateRange {
return { from: '2024-05-01', to: '2024-05-31' }
}
export function rangeForPreset(preset: string): DateRange {
switch (preset) {
case '7d':
return { from: '2024-05-25', to: '2024-05-31' }
case '14d':
return { from: '2024-05-18', to: '2024-05-31' }
case '30d':
return { from: '2024-05-01', to: '2024-05-31' }
case 'qtd':
return { from: '2024-04-01', to: '2024-05-31' }
default:
return defaultRange()
}
}
@@ -0,0 +1,91 @@
// Presentation-layer formatting helpers shared across the dashboard.
// Pure functions only — no React, no data fetching.
export function formatCurrency(amount: number, currency: string = 'USD'): string {
if (!Number.isFinite(amount)) {
return '—'
}
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
maximumFractionDigits: 0
}).format(amount)
}
export function formatCurrencyPrecise(amount: number, currency: string = 'USD'): string {
if (!Number.isFinite(amount)) {
return '—'
}
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(amount)
}
export function formatNumber(value: number): string {
if (!Number.isFinite(value)) {
return '—'
}
return new Intl.NumberFormat('en-US').format(value)
}
export function formatCompact(value: number): string {
if (!Number.isFinite(value)) {
return '—'
}
return new Intl.NumberFormat('en-US', {
notation: 'compact',
maximumFractionDigits: 1
}).format(value)
}
export function formatPercent(ratio: number, digits: number = 1): string {
if (!Number.isFinite(ratio)) {
return '—'
}
return `${(ratio * 100).toFixed(digits)}%`
}
export function formatSignedPercent(ratio: number, digits: number = 1): string {
const sign = ratio > 0 ? '+' : ''
return `${sign}${formatPercent(ratio, digits)}`
}
export function formatDate(iso: string): string {
const date = new Date(iso)
if (Number.isNaN(date.getTime())) {
return iso
}
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
})
}
export function formatDateShort(iso: string): string {
const date = new Date(iso)
if (Number.isNaN(date.getTime())) {
return iso
}
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric'
})
}
export function titleCase(value: string): string {
return value
.split(/[\s_-]+/)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
.join(' ')
}
export function truncate(value: string, max: number = 32): string {
if (value.length <= max) {
return value
}
return `${value.slice(0, max - 1)}`
}
+25 -1
View File
@@ -1,4 +1,6 @@
import { readFile } from "node:fs/promises";
import { readFile, stat } from "node:fs/promises";
import { basename } from "node:path";
import { loadAppFixtureForEval } from "../adapters/frontend/core/app/appFixtureLoader";
import {
runGlobalEval,
type GlobalLiveEditorDraftFixture,
@@ -76,6 +78,28 @@ export function createGlobalModeRunner(
}
async function loadGlobalInitialFixture(path: string): Promise<GlobalInitialFixture> {
if ((await stat(path)).isDirectory()) {
const { initialFrontend, initialBackend, initialDatatables } =
await loadAppFixtureForEval(path);
const name = basename(path);
return {
workspace: {
apps: [
{
path: `f/evals/global/${name}`,
summary: name,
value: {
files: initialFrontend,
runnables: initialBackend,
data: initialDatatables,
},
},
],
},
liveEditorDrafts: [],
};
}
const parsed = JSON.parse(await readFile(path, "utf8")) as GlobalInitialFixture;
return {
workspace: parsed.workspace ?? {},
@@ -1328,6 +1328,378 @@ describe('global AI tools', () => {
expect(getBackendDraft('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined()
})
const deployedAppWithFile = (filePath: string, content: string) =>
({
path: 'f/apps/report',
summary: 'deployed app',
versions: [5],
value: { files: { [filePath]: content }, runnables: {}, data: {} }
}) as any
it('truncates a large frontend file to a head slice with a paging annotation', async () => {
const lines = Array.from({ length: 2000 }, (_, i) => `line ${i + 1}`)
vi.mocked(AppService.getAppByPath).mockResolvedValueOnce(
deployedAppWithFile('/big.tsx', lines.join('\n'))
)
const result = await callGlobalTool('read_app_file', {
path: 'f/apps/report',
file_path: '/big.tsx'
})
expect(result).toContain('lines 1-1500 of 2000.')
expect(result).toContain('offset=1501')
expect(result).toContain('line 1500')
expect(result).not.toContain('line 1501')
})
it('returns the requested window when offset and limit are given', async () => {
const lines = Array.from({ length: 2000 }, (_, i) => `line ${i + 1}`)
vi.mocked(AppService.getAppByPath).mockResolvedValueOnce(
deployedAppWithFile('/big.tsx', lines.join('\n'))
)
const result = await callGlobalTool('read_app_file', {
path: 'f/apps/report',
file_path: '/big.tsx',
offset: 5,
limit: 3
})
expect(result).toContain('lines 5-7 of 2000.')
expect(result).toContain('line 5\nline 6\nline 7')
expect(result).not.toContain('line 4')
expect(result).not.toContain('line 8')
})
it('truncates at the character budget for files with very long lines', async () => {
const bigLine = 'x'.repeat(30_000)
vi.mocked(AppService.getAppByPath).mockResolvedValueOnce(
deployedAppWithFile('/min.tsx', [bigLine, bigLine, bigLine].join('\n'))
)
const result = await callGlobalTool('read_app_file', {
path: 'f/apps/report',
file_path: '/min.tsx'
})
expect(result).toContain(
'lines 1-3 of 3, truncated to the first 50000 of 90002 chars.'
)
expect(result).toContain('the file is likely minified')
expect(result.split('\n\n')[1]).toHaveLength(50_000)
})
it('caps a single-line generated file at the character budget', async () => {
const bigLine = 'x'.repeat(60_000)
vi.mocked(AppService.getAppByPath).mockResolvedValueOnce(
deployedAppWithFile('/generated.js', bigLine)
)
const result = await callGlobalTool('read_app_file', {
path: 'f/apps/report',
file_path: '/generated.js'
})
expect(result).toContain(
'lines 1-1 of 1, truncated to the first 50000 of 60000 chars.'
)
expect(result).toContain('re-read with a smaller limit')
expect(result.split('\n\n')[1]).toBe('x'.repeat(50_000))
})
it('reports an offset past the end of the file plainly', async () => {
const lines = Array.from({ length: 10 }, (_, i) => `line ${i + 1}`)
vi.mocked(AppService.getAppByPath).mockResolvedValueOnce(
deployedAppWithFile('/small.tsx', lines.join('\n'))
)
await expect(
callGlobalTool('read_app_file', {
path: 'f/apps/report',
file_path: '/small.tsx',
offset: 50
})
).resolves.toBe('offset 50 is past the end of the file (10 lines).')
})
// Deterministic micro-benchmark: measures how much context the read_app_file cap
// saves over a realistic big-project read pattern, isolated from model
// nondeterminism. "Baseline" is the old behavior (whole file returned on every
// read); "actual" is the current line cap + char budget + paging. Asserting the
// ratio also guards against a future change silently weakening the savings.
it('micro-benchmark: the read cap cuts returned context for a realistic read pattern', async () => {
const bigContent = Array.from({ length: 5000 }, (_, i) => `const row${i} = ${i};`).join('\n')
const minified = 'a'.repeat(200_000) // single long line (e.g. a generated bundle)
const appValue = {
path: 'f/apps/report',
summary: 'big app',
versions: [5],
value: { files: { '/big.tsx': bigContent, '/min.js': minified }, runnables: {}, data: {} }
} as any
const fullSize: Record<string, number> = {
'/big.tsx': bigContent.length,
'/min.js': minified.length
}
// A plausible pass over a large app: read a big file head, page deeper into it,
// then hit a generated bundle (capped at the char budget). The old tool returned
// every file in full on every read.
const sequence = [
{ file_path: '/big.tsx' }, // 1. big file head (line cap)
{ file_path: '/big.tsx', offset: 1501 }, // 2. next line chunk
{ file_path: '/min.js' } // 3. minified bundle head (char budget)
]
let baselineChars = 0
let actualChars = 0
const perRead: number[] = []
for (const read of sequence) {
baselineChars += fullSize[read.file_path]
vi.mocked(AppService.getAppByPath).mockResolvedValueOnce(appValue)
const out = await callGlobalTool('read_app_file', { path: 'f/apps/report', ...read })
actualChars += out.length
perRead.push(out.length)
}
const reductionPct = Math.round((1 - actualChars / baselineChars) * 100)
// Surfaced when the suite runs so the benchmark is readable, not just asserted.
// eslint-disable-next-line no-console
console.log(
`[read_app_file micro-benchmark] baseline=${baselineChars} chars, actual=${actualChars} chars ` +
`(per-read ${perRead.join(', ')}), reduction=${reductionPct}%`
)
// Each capped read is far smaller than the whole file it came from:
expect(perRead[0]).toBeLessThan(fullSize['/big.tsx']) // head slice < whole file
expect(perRead[1]).toBeLessThan(fullSize['/big.tsx']) // a paged line chunk too
expect(perRead[2]).toBeLessThan(51_000) // ~50k char budget + a short annotation
// Overall: well under half the bytes the old tool would have returned.
expect(actualChars).toBeLessThan(baselineChars * 0.5)
})
// A multi-file app: the revenue helper is referenced in three frontend files
// and one inline backend runnable; a generated file also mentions it (and must
// be excluded). Mirrors the analytics_dashboard fixture's "symbol spread".
const searchAppValue = () =>
({
path: 'f/apps/report',
summary: 'search app',
versions: [5],
value: {
files: {
'/lib/aggregations.ts':
'export function computeRevenue(o) {\n return o.unitPrice\n}\n',
'/components/SummaryPanel.tsx':
'import { computeRevenue } from "../lib/aggregations"\nconst total = computeRevenue(order)\n',
'/components/OrdersTable.tsx': 'const r = computeRevenue(row)\n// renders revenue\n',
'/styles.css': '.revenue { color: green }\n',
'/wmill.d.ts': 'declare function computeRevenue(o: any): number\n'
},
runnables: {
computeSummary: {
type: 'inline',
inlineScript: {
language: 'bun',
content: 'export async function main() {\n return computeRevenue\n}\n'
}
}
},
data: {}
}
}) as any
it('greps across frontend files and inline runnables, returning file:line rows', async () => {
vi.mocked(AppService.getAppByPath).mockResolvedValueOnce(searchAppValue())
const result = await callGlobalTool('search_app', {
path: 'f/apps/report',
query: 'computeRevenue'
})
// header counts every match across the (non-generated) files, without echoing the query
expect(result).toMatch(/\d+ match(?:es)? in \d+ files?/)
// frontend rows use read_app_file's leading-slash addressing
expect(result).toContain('/lib/aggregations.ts')
expect(result).toContain('1: export function computeRevenue(o) {')
expect(result).toContain('/components/SummaryPanel.tsx')
// inline runnable rows use the backend/<key>/main.<ext> addressing
expect(result).toContain('backend/computeSummary/main.ts')
// generated files are never searched
expect(result).not.toContain('/wmill.d.ts')
})
it('matches case-insensitively', async () => {
vi.mocked(AppService.getAppByPath).mockResolvedValueOnce(searchAppValue())
const result = await callGlobalTool('search_app', {
path: 'f/apps/report',
query: 'COMPUTEREVENUE' // upper-case query still matches computeRevenue
})
expect(result).toContain('/lib/aggregations.ts')
expect(result).toContain('export function computeRevenue(o) {')
})
it('filters by a basename glob (matches nested files)', async () => {
vi.mocked(AppService.getAppByPath).mockResolvedValueOnce(searchAppValue())
const result = await callGlobalTool('search_app', {
path: 'f/apps/report',
query: 'computeRevenue',
file_glob: '*.tsx'
})
expect(result).toContain('/components/SummaryPanel.tsx')
expect(result).toContain('/components/OrdersTable.tsx')
expect(result).not.toContain('/lib/aggregations.ts')
expect(result).not.toContain('backend/computeSummary/main.ts')
})
it('filters by a path glob (e.g. backend/**)', async () => {
vi.mocked(AppService.getAppByPath).mockResolvedValueOnce(searchAppValue())
const result = await callGlobalTool('search_app', {
path: 'f/apps/report',
query: 'computeRevenue',
file_glob: 'backend/**'
})
expect(result).toContain('backend/computeSummary/main.ts')
expect(result).not.toContain('/lib/aggregations.ts')
})
it('reports zero matches with a hint instead of an empty result', async () => {
vi.mocked(AppService.getAppByPath).mockResolvedValueOnce(searchAppValue())
const result = await callGlobalTool('search_app', {
path: 'f/apps/report',
query: 'nonexistent_symbol_xyz'
})
expect(result).toContain('No matches')
expect(result).toContain('Try a broader')
})
it('truncates very long matching lines to keep results sparse', async () => {
const longLine = `const x = "${'q'.repeat(5000)} computeRevenue"`
vi.mocked(AppService.getAppByPath).mockResolvedValueOnce({
path: 'f/apps/report',
summary: 'app',
versions: [5],
value: { files: { '/min.js': longLine }, runnables: {}, data: {} }
} as any)
const result = await callGlobalTool('search_app', {
path: 'f/apps/report',
query: 'computeRevenue'
})
expect(result).toContain('[line truncated]')
expect(result.length).toBeLessThan(1000)
})
it('caps the number of match rows and says it truncated', async () => {
const manyLines = Array.from({ length: 500 }, (_, i) => `hit ${i}`).join('\n')
vi.mocked(AppService.getAppByPath).mockResolvedValueOnce({
path: 'f/apps/report',
summary: 'app',
versions: [5],
value: { files: { '/big.tsx': manyLines }, runnables: {}, data: {} }
} as any)
const result = await callGlobalTool('search_app', {
path: 'f/apps/report',
query: 'hit',
max_matches: 50
})
expect(result).toContain('500 matches')
expect(result).toContain('showing the first 50')
// 50 capped match lines, each rendered with its fixed context window (deduped),
// so the body is bounded near max_matches and nowhere near the 500 total.
const rows = result.split('\n').filter((l) => /^\s+\d+: /.test(l)).length
expect(rows).toBeGreaterThanOrEqual(50)
expect(rows).toBeLessThan(60)
})
it('counts every file with a match, even matches past the render cap', async () => {
// The first (sorted) file exhausts max_matches; the later file's match falls
// past the cap but the symbol still lives there, so the header must count it.
vi.mocked(AppService.getAppByPath).mockResolvedValueOnce({
path: 'f/apps/report',
summary: 'app',
versions: [5],
value: {
files: { '/a.tsx': 'hit\nhit\nhit\nhit\nhit', '/b.tsx': 'hit' },
runnables: {},
data: {}
}
} as any)
const result = await callGlobalTool('search_app', {
path: 'f/apps/report',
query: 'hit',
max_matches: 3
})
expect(result).toContain('6 matches in 2 files')
expect(result).toContain('showing the first 3')
})
// Deterministic micro-benchmark: how much context a single search_app call
// saves over locating a symbol by reading the candidate files whole. Baseline
// is the conservative "read only the files that actually contain the symbol"
// path (a model without search must read at least those in full); the real
// saving is larger because, lacking search, a model often reads non-matching
// files too. Isolated from model nondeterminism so it can gate regressions.
it('micro-benchmark: search_app locates a symbol far cheaper than reading files', async () => {
const fileBodies: Record<string, string> = {}
// 8 component files, 3 of which reference the symbol, each ~120 lines.
for (let f = 0; f < 8; f++) {
const lines = Array.from({ length: 120 }, (_, i) =>
f < 3 && i === 60 ? ` return computeRevenue(order${f})` : ` const v${i} = ${i} // padding`
)
fileBodies[`/components/File${f}.tsx`] = lines.join('\n')
}
const appValue = {
path: 'f/apps/report',
summary: 'big app',
versions: [5],
value: {
files: fileBodies,
runnables: {},
data: {}
}
} as any
vi.mocked(AppService.getAppByPath).mockResolvedValueOnce(appValue)
const searchOut = await callGlobalTool('search_app', {
path: 'f/apps/report',
query: 'computeRevenue'
})
// Baseline: the bytes a model must pull to gather the same locations by
// reading each matching file in full.
const matchingFiles = Object.entries(fileBodies).filter(([, body]) =>
body.includes('computeRevenue')
)
const baselineChars = matchingFiles.reduce((sum, [, body]) => sum + body.length, 0)
const actualChars = searchOut.length
const reductionPct = Math.round((1 - actualChars / baselineChars) * 100)
// eslint-disable-next-line no-console
console.log(
`[search_app micro-benchmark] baseline=${baselineChars} chars (read ${matchingFiles.length} files whole), ` +
`actual=${actualChars} chars (one search), reduction=${reductionPct}%`
)
// The search surfaced exactly the 3 locations…
expect(matchingFiles.length).toBe(3)
expect((searchOut.match(/computeRevenue/g) ?? []).length).toBeGreaterThanOrEqual(3)
// …at a tiny fraction of reading those files whole.
expect(actualChars).toBeLessThan(baselineChars * 0.15)
})
it('does not persist a raw app draft when patch_app_file validation fails', async () => {
vi.mocked(AppService.getAppByPath).mockResolvedValueOnce({
path: 'f/apps/report',
@@ -528,6 +528,43 @@ const readAppFileSchema = z.object({
.string()
.describe(
'Frontend file path like /index.tsx, or backend inline runnable path like backend/<key>/main.ts (or main.py).'
),
offset: z
.number()
.int()
.min(1)
.optional()
.describe('1-based line number to start reading from. Use to page through a large file.'),
limit: z
.number()
.int()
.min(1)
.optional()
.describe(
'Maximum number of lines to return. Large files are truncated by default; pass offset/limit to read a specific line range.'
)
})
const searchAppSchema = z.object({
path: z.string().describe('Workspace path of the app, e.g. f/folder/name.'),
query: z
.string()
.describe(
'Literal substring to find across all app files (case-insensitive) — not a regex, so spaces and operators match verbatim. Returns matching file:line rows, not file bodies. To find call sites and skip similarly-named helpers, include the call paren (e.g. "formatCurrency(" matches calls but not "formatCurrencyPrecise"). Then read_app_file to inspect the ranges.'
),
file_glob: z
.string()
.optional()
.describe(
'Optional path filter. A pattern without "/" matches the file name anywhere (e.g. "*.tsx"); a pattern with "/" matches the full path (e.g. "/components/*", "backend/**"). Supports * (any chars except /), ** (any chars), and ?.'
),
max_matches: z
.number()
.int()
.min(1)
.optional()
.describe(
'Maximum number of matching lines to return (default 100, hard cap 200); each is shown with a few lines of surrounding context, so the output has more rows than this.'
)
})
@@ -1400,7 +1437,8 @@ function getAppInstructions(): string {
- \`/wmill.d.ts\` (or \`wmill.ts\`) is generated automatically from the backend runnables — never write it directly.
- Inline runnables only support \`bun\` or \`python3\` in chat. Path runnables (\`script\`/\`flow\`/\`hubscript\`) reference an existing item.
- Use \`deploy_workspace_item\` after explicit user deploy intent. The deploy tool bundles JS/CSS before saving the raw app.
- Use \`read_workspace_item\` with \`type: 'app'\` for a metadata summary (file paths and runnable list, no contents). Use \`read_app_file\` to read an individual file.
- Use \`read_workspace_item\` with \`type: 'app'\` for a metadata summary (file paths and runnable list, no contents). Use \`read_app_file\` to read an individual file; large files are truncated to a head slice, so pass \`offset\`/\`limit\` to page through the rest rather than re-reading the whole file.
- To find where a symbol or string lives across the app, call \`search_app\` (greps every frontend file and inline runnable, returns matching \`file:line\` rows) instead of reading files one by one — then \`read_app_file\` only the ranges you need. The loop is list (\`read_workspace_item\`) → locate (\`search_app\`) → inspect (\`read_app_file\` with \`offset\`/\`limit\`).
- Note: the authoring reference below mentions the CLI on-disk layout (\`backend/<id>.<ext>\`, \`raw_app.yaml\`, \`sql_to_apply/\`). That layout is only relevant for the terminal workflow — in chat, apps are addressed via the tool surface above.
# Windmill raw app authoring reference
@@ -1934,13 +1972,24 @@ export const globalTools: Tool<{}>[] = [
def: createToolDef(
readAppFileSchema,
'read_app_file',
'Read one raw app frontend file or inline backend runnable.'
'Read one raw app frontend file or inline backend runnable. Large files are truncated to a head slice; pass offset/limit to page through the rest.'
),
fn: async (ctx) => {
const parsed = readAppFileSchema.parse(ctx.args)
return readAppFile(parsed, ctx)
}
},
{
def: createToolDef(
searchAppSchema,
'search_app',
"Grep across all of a raw app's frontend files and inline backend runnables in one call. Returns matching file:line rows (capped), not file bodies — use it to locate a symbol or string before read_app_file instead of reading whole files one by one."
),
fn: async (ctx) => {
const parsed = searchAppSchema.parse(ctx.args)
return searchApp(parsed, ctx)
}
},
{
def: createToolDef(
writeAppFileSchema,
@@ -2108,6 +2157,7 @@ type WriteDraftCtx = {
// handlers below would route a backgrounded session's tool call to whatever
// session the user happens to be viewing.
export type SessionToolHelpers = { sessionId?: string }
export type GlobalToolHelpers = SessionToolHelpers & {
testActiveFlow?: (args?: Record<string, any>) => Promise<string | undefined>
}
@@ -2967,8 +3017,89 @@ async function initApp(
}))
}
// read_app_file caps: a large frontend file or inline runnable would otherwise
// enter context in full and persist for the rest of the session. Default to a
// head slice with a pointer to page further; the model widens with offset/limit.
// The char budget is a hard ceiling on a single read: a selected line window over
// it is truncated and the model is told to narrow the line range. There is no
// char-level paging, so a single line longer than the budget can't be read past —
// add paging here if minified/long-line files must be fully readable.
const READ_APP_FILE_DEFAULT_LINE_LIMIT = 1500
const READ_APP_FILE_CHAR_BUDGET = 50_000
type AppFileSlice = {
body: string
startLine: number
endLine: number
requestedStartLine: number
totalLines: number
lineWindowChars: number
charTruncated: boolean
truncated: boolean
}
function sliceAppFileForRead(content: string, offset?: number, limit?: number): AppFileSlice {
const lines = content.split('\n')
const totalLines = lines.length
const requestedStartLine = offset ?? 1
const start = Math.min(Math.max(requestedStartLine - 1, 0), totalLines)
const lineLimit = limit ?? READ_APP_FILE_DEFAULT_LINE_LIMIT
const end = Math.min(start + lineLimit, totalLines)
const selectedBody = lines.slice(start, end).join('\n')
const lineWindowChars = selectedBody.length
const body = selectedBody.slice(0, READ_APP_FILE_CHAR_BUDGET)
const charTruncated = lineWindowChars > READ_APP_FILE_CHAR_BUDGET
return {
body,
startLine: start + 1,
endLine: end,
requestedStartLine,
totalLines,
lineWindowChars,
charTruncated,
truncated: start > 0 || end < totalLines || charTruncated
}
}
function formatAppFileReadRangeLabel(slice: AppFileSlice): string {
const lineRange = `lines ${slice.startLine}-${slice.endLine} of ${slice.totalLines}`
if (!slice.charTruncated) {
return lineRange
}
return `${lineRange}, truncated to the first ${READ_APP_FILE_CHAR_BUDGET} of ${slice.lineWindowChars} chars`
}
// Small files (returned whole, starting at line 1) keep the raw body so
// patch_app_file's exact-match stays trivial; truncated/windowed reads get a
// one-line annotation describing the range (not part of the file).
function formatAppFileReadResult(slice: AppFileSlice): string {
// offset past the last line: report it plainly instead of a backwards range.
if (slice.requestedStartLine > slice.totalLines) {
return `offset ${slice.requestedStartLine} is past the end of the file (${slice.totalLines} lines).`
}
if (!slice.truncated && slice.startLine === 1) {
return slice.body
}
let more = ''
if (slice.charTruncated) {
more = ` Reached the ${READ_APP_FILE_CHAR_BUDGET}-char limit; re-read with a smaller limit (fewer lines). If a single line exceeds the limit the file is likely minified and not readable this way.`
} else if (slice.endLine < slice.totalLines) {
more = ` Call read_app_file again with offset=${slice.endLine + 1} to continue.`
}
// No tool-name/path prefix: the model already has them from the call args. The
// range line orients it; the body follows after a blank line.
return `${formatAppFileReadRangeLabel(slice)}.${more}\n\n${slice.body}`
}
async function readAppFile(
args: { path: string; file_path: string },
args: {
path: string
file_path: string
offset?: number
limit?: number
},
ctx: WriteDraftCtx
): Promise<string> {
const { workspace, toolId, toolCallbacks } = ctx
@@ -2979,18 +3110,188 @@ async function readAppFile(
const value = await loadAppValueForRead(args.path, workspace)
let content: string
if (target.kind === 'frontend') {
const content = value.files[target.filePath]
if (content === undefined) {
const frontend = value.files[target.filePath]
if (frontend === undefined) {
throw new Error(`Frontend file "${target.filePath}" not found in app "${args.path}".`)
}
toolCallbacks.setToolStatus(toolId, { content: `Read ${target.filePath}` })
return content
content = frontend
} else {
content = getInlineRunnableContent(value, target, args.path).content
}
const { content } = getInlineRunnableContent(value, target, args.path)
const slice = sliceAppFileForRead(content, args.offset, args.limit)
toolCallbacks.setToolStatus(toolId, { content: `Read ${target.filePath}` })
return content
return formatAppFileReadResult(slice)
}
// search_app caps: a single query must stay sparse and cheap even when it hits a
// minified bundle or a 5k-line data module. Per-line and total-output caps bound
// the result the same way read_app_file's char budget bounds one file read; the
// match cap keeps a broad query from flooding context instead of locating it.
const SEARCH_APP_DEFAULT_MAX_MATCHES = 100
const SEARCH_APP_MAX_MATCHES_CEILING = 200
const SEARCH_APP_MAX_LINE_CHARS = 200
const SEARCH_APP_TOTAL_CHAR_BUDGET = 12_000
// Fixed surrounding-context window per match, kept off the tool schema to keep it
// lean. Bump if matches need more context than the line ± this.
const SEARCH_APP_CONTEXT_LINES = 2
type AppSearchableFile = { filePath: string; content: string }
// The files search_app scans: frontend files (minus generated ones) plus inline
// backend runnables, each addressed exactly as read_app_file expects so a match
// row's path can be passed straight back to read_app_file.
function collectSearchableAppFiles(value: AppDraftValue): AppSearchableFile[] {
const files: AppSearchableFile[] = []
for (const [filePath, content] of Object.entries(value.files)) {
if (GENERATED_APP_FILE_PATHS.has(filePath)) continue
if (typeof content === 'string') files.push({ filePath, content })
}
for (const [key, runnable] of Object.entries(value.runnables)) {
const persisted = runnable as PersistedRunnable | undefined
const content = persisted?.inlineScript?.content
if (typeof content !== 'string') continue
files.push({ filePath: `backend/${key}/main.${getInlineScriptExtension(persisted)}`, content })
}
return files
}
// Minimal glob: * = any chars except '/', ** = any chars, ? = single non-slash.
// A pattern without '/' matches the file name only (ripgrep-style), so "*.tsx"
// finds nested files; a pattern with '/' matches the full path.
function appFileMatchesGlob(filePath: string, glob: string): boolean {
const hasSlash = glob.includes('/')
const subject = hasSlash ? filePath : filePath.slice(filePath.lastIndexOf('/') + 1)
const body = glob
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
.replace(/\*\*/g, '\u0000')
.replace(/\*/g, '[^/]*')
.replace(/\u0000/g, '.*')
.replace(/\?/g, '[^/]')
try {
return new RegExp(`^${body}$`).test(subject)
} catch {
return false
}
}
type AppSearchMatch = { filePath: string; line: number; text: string }
async function searchApp(
args: {
path: string
query: string
file_glob?: string
max_matches?: number
},
ctx: WriteDraftCtx
): Promise<string> {
const { workspace, toolId, toolCallbacks } = ctx
const query = args.query
if (query.length === 0) {
throw new Error('search_app requires a non-empty query.')
}
toolCallbacks.setToolStatus(toolId, {
content: `Searching app "${args.path}" for "${query}"...`
})
const value = await loadAppValueForRead(args.path, workspace)
const maxMatches = Math.min(
args.max_matches ?? SEARCH_APP_DEFAULT_MAX_MATCHES,
SEARCH_APP_MAX_MATCHES_CEILING
)
const contextLines = SEARCH_APP_CONTEXT_LINES
const needle = query.toLowerCase()
let files = collectSearchableAppFiles(value).sort((a, b) => a.filePath.localeCompare(b.filePath))
if (args.file_glob) {
files = files.filter((f) => appFileMatchesGlob(f.filePath, args.file_glob as string))
}
const matches: AppSearchMatch[] = []
let totalMatchCount = 0
// Cap on matching LINES, not pushed rows — each match expands to its context
// window, so counting rows would make `max_matches`/"showing the first N" wrong.
let renderedMatchCount = 0
let fileCount = 0
let truncated = false
for (const file of files) {
const lines = file.content.split('\n')
let fileHadMatch = false
for (let i = 0; i < lines.length; i++) {
if (!lines[i].toLowerCase().includes(needle)) continue
totalMatchCount++
// Count the file on its first match, before the render cap, so the
// "N matches in M files" header counts every file with the symbol — not
// only the ones whose matches landed in the rendered slice (find-all-usages).
fileHadMatch = true
if (renderedMatchCount >= maxMatches) {
truncated = true
continue
}
renderedMatchCount++
const lo = Math.max(0, i - contextLines)
const hi = Math.min(lines.length - 1, i + contextLines)
for (let j = lo; j <= hi; j++) {
matches.push({ filePath: file.filePath, line: j + 1, text: lines[j] })
}
}
if (fileHadMatch) fileCount++
}
if (totalMatchCount === 0) {
toolCallbacks.setToolStatus(toolId, { content: `No matches for "${query}"` })
return `No matches. Try a broader or differently-spelled term${
args.file_glob ? ', or drop the file_glob' : ''
}.`
}
// No tool-name/query prefix: the model already has them from the call args.
const header = `${totalMatchCount} match${
totalMatchCount === 1 ? '' : 'es'
} in ${fileCount} file${fileCount === 1 ? '' : 's'}${
truncated ? ` (showing the first ${maxMatches}; narrow with file_glob or a more specific query)` : ''
}`
const out: string[] = [header]
let currentFile = ''
let budgetSpent = header.length
let budgetHit = false
const seen = new Set<string>()
for (const m of matches) {
// context windows of adjacent matches overlap — show each source line once.
const dedupeKey = `${m.filePath}:${m.line}`
if (seen.has(dedupeKey)) continue
seen.add(dedupeKey)
const text =
m.text.length > SEARCH_APP_MAX_LINE_CHARS
? `${m.text.slice(0, SEARCH_APP_MAX_LINE_CHARS)}… [line truncated]`
: m.text
const fileHeader = m.filePath === currentFile ? '' : `${m.filePath}\n`
const row = `${fileHeader} ${m.line}: ${text}`
if (budgetSpent + row.length + 1 > SEARCH_APP_TOTAL_CHAR_BUDGET) {
budgetHit = true
break
}
if (fileHeader) currentFile = m.filePath
out.push(row)
budgetSpent += row.length + 1
}
if (budgetHit) {
out.push(
`… output truncated at the context budget — narrow with file_glob or a more specific query.`
)
}
toolCallbacks.setToolStatus(toolId, {
content: `Found ${totalMatchCount} match${totalMatchCount === 1 ? '' : 'es'} in ${fileCount} file${
fileCount === 1 ? '' : 's'
}`
})
return out.join('\n')
}
async function writeAppFile(