mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 16:02:10 +00:00
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:
@@ -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,
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
+68
@@ -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'
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "Compute Summary",
|
||||
"language": "bun"
|
||||
}
|
||||
+51
@@ -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
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "Export Report",
|
||||
"language": "bun"
|
||||
}
|
||||
+40
@@ -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() }
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "Load Metrics",
|
||||
"language": "bun"
|
||||
}
|
||||
+54
@@ -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 }
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "Load Orders",
|
||||
"language": "bun"
|
||||
}
|
||||
+45
@@ -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>
|
||||
)
|
||||
}
|
||||
+28
@@ -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>
|
||||
)
|
||||
}
|
||||
+51
@@ -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>
|
||||
)
|
||||
}
|
||||
+59
@@ -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>
|
||||
)
|
||||
}
|
||||
+40
@@ -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>
|
||||
)
|
||||
}
|
||||
+18
@@ -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>
|
||||
)
|
||||
}
|
||||
+117
@@ -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>
|
||||
)
|
||||
}
|
||||
+52
@@ -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>
|
||||
)
|
||||
}
|
||||
+49
@@ -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>
|
||||
)
|
||||
}
|
||||
+50
@@ -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>
|
||||
)
|
||||
}
|
||||
+26
@@ -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>
|
||||
)
|
||||
}
|
||||
+51
@@ -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>
|
||||
)
|
||||
}
|
||||
+55
@@ -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>
|
||||
)
|
||||
}
|
||||
+5051
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
+149
@@ -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)}…`
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user