@aryasaatvik/motel 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +180 -0
- package/LICENSE +21 -0
- package/README.md +137 -0
- package/package.json +119 -0
- package/skills/motel-debug/SKILL.md +203 -0
- package/skills/motel-debug/references/effect.md +38 -0
- package/src/App.tsx +414 -0
- package/src/StartupGate.test.ts +80 -0
- package/src/StartupGate.tsx +263 -0
- package/src/cli.ts +205 -0
- package/src/config.ts +45 -0
- package/src/daemon.test.ts +547 -0
- package/src/daemon.ts +486 -0
- package/src/domain.test.ts +62 -0
- package/src/domain.ts +291 -0
- package/src/httpApi.ts +391 -0
- package/src/httpListPolicy.test.ts +76 -0
- package/src/httpListPolicy.ts +129 -0
- package/src/index.tsx +25 -0
- package/src/instructions.ts +72 -0
- package/src/launchAgent.test.ts +430 -0
- package/src/launchAgent.ts +462 -0
- package/src/localServer.ts +568 -0
- package/src/locator.ts +138 -0
- package/src/mcp.ts +433 -0
- package/src/motel.test.ts +176 -0
- package/src/motel.ts +88 -0
- package/src/motelClient.ts +353 -0
- package/src/opentui-jsx.d.ts +11 -0
- package/src/otlp.test.ts +65 -0
- package/src/otlp.ts +162 -0
- package/src/otlpProtobuf.ts +35 -0
- package/src/queryFilters.ts +39 -0
- package/src/registry.ts +115 -0
- package/src/runtime.ts +40 -0
- package/src/server.ts +11 -0
- package/src/services/AsyncIngest.ts +82 -0
- package/src/services/LogQueryService.ts +29 -0
- package/src/services/TelemetryQuery.ts +62 -0
- package/src/services/TelemetryStore.ts +2554 -0
- package/src/services/TraceQueryService.ts +43 -0
- package/src/services/ingestRpc.ts +39 -0
- package/src/services/queryRpc.ts +15 -0
- package/src/services/telemetryQueryWorker.ts +32 -0
- package/src/services/telemetryWorker.ts +59 -0
- package/src/startupBench.ts +19 -0
- package/src/startupLifecycle.ts +57 -0
- package/src/storybook/aiChatStory.tsx +244 -0
- package/src/storybook/fixtures/errorState.ts +44 -0
- package/src/storybook/fixtures/imagePaste.ts +34 -0
- package/src/storybook/fixtures/index.ts +62 -0
- package/src/storybook/fixtures/kitchenSink.ts +148 -0
- package/src/storybook/fixtures/rawPrompt.ts +15 -0
- package/src/storybook/fixtures/short.ts +27 -0
- package/src/storybook/fixtures/toolHeavy.ts +65 -0
- package/src/telemetry.test.ts +1053 -0
- package/src/ui/AiChatView.tsx +308 -0
- package/src/ui/AttrFilterModal.tsx +120 -0
- package/src/ui/ServiceLogs.tsx +115 -0
- package/src/ui/SpanContentView.tsx +184 -0
- package/src/ui/SpanDetail.tsx +215 -0
- package/src/ui/SpanDetailFull.tsx +224 -0
- package/src/ui/SpanDetailPane.tsx +90 -0
- package/src/ui/TraceDetailsPane.tsx +175 -0
- package/src/ui/TraceList.tsx +254 -0
- package/src/ui/Waterfall.tsx +389 -0
- package/src/ui/aiChatModel.test.ts +391 -0
- package/src/ui/aiChatModel.ts +773 -0
- package/src/ui/aiState.ts +59 -0
- package/src/ui/app/TraceListPane.tsx +39 -0
- package/src/ui/app/TraceWorkspace.tsx +452 -0
- package/src/ui/app/useAppLayout.ts +101 -0
- package/src/ui/app/useTraceScreenData.ts +590 -0
- package/src/ui/atoms.ts +131 -0
- package/src/ui/cachedLoader.test.ts +23 -0
- package/src/ui/cachedLoader.ts +60 -0
- package/src/ui/filterParser.test.ts +56 -0
- package/src/ui/filterParser.ts +45 -0
- package/src/ui/format.ts +128 -0
- package/src/ui/loaders.ts +101 -0
- package/src/ui/persistence.ts +41 -0
- package/src/ui/primitives.tsx +198 -0
- package/src/ui/state.ts +6 -0
- package/src/ui/theme.ts +160 -0
- package/src/ui/traceDetailsWidth.repro.test.ts +126 -0
- package/src/ui/traceSortNav.repro.seed.ts +62 -0
- package/src/ui/traceSortNav.repro.test.ts +231 -0
- package/src/ui/useAttrFilterPicker.ts +89 -0
- package/src/ui/useKeyboardNav.ts +1023 -0
- package/src/ui/waterfallFilter.test.ts +84 -0
- package/src/ui/waterfallFilter.ts +59 -0
- package/src/ui/waterfallModel.ts +130 -0
- package/src/ui/waterfallNav.repro.seed.ts +86 -0
- package/src/ui/waterfallNav.repro.test.ts +271 -0
- package/src/ui/waterfallNav.test.ts +453 -0
- package/src/ui/waterfallNav.ts +75 -0
- package/web/dist/assets/index-B01z9BaO.css +2 -0
- package/web/dist/assets/index-CYlBaY9O.js +22 -0
- package/web/dist/index.html +13 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Effect, Layer, Context } from "effect"
|
|
2
|
+
import type { AiCallDetail, SpanItem, TraceItem, TraceSummaryItem } from "../domain.js"
|
|
3
|
+
import { TelemetryStore } from "./TelemetryStore.js"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Compatibility adapter for consumers importing the historical trace query module.
|
|
7
|
+
* New internal callers should use TelemetryStoreReadonly directly.
|
|
8
|
+
*/
|
|
9
|
+
export class TraceQueryService extends Context.Service<
|
|
10
|
+
TraceQueryService,
|
|
11
|
+
{
|
|
12
|
+
readonly listServices: Effect.Effect<readonly string[], Error>
|
|
13
|
+
readonly listRecentTraces: (serviceName: string, options?: { readonly lookbackMinutes?: number; readonly limit?: number; readonly cursorStartedAtMs?: number; readonly cursorTraceId?: string }) => Effect.Effect<readonly TraceItem[], Error>
|
|
14
|
+
readonly listTraceSummaries: (serviceName: string | null, options?: { readonly lookbackMinutes?: number; readonly limit?: number; readonly cursorStartedAtMs?: number; readonly cursorTraceId?: string }) => Effect.Effect<readonly TraceSummaryItem[], Error>
|
|
15
|
+
readonly searchTraceSummaries: (input: { readonly serviceName?: string | null; readonly operation?: string | null; readonly status?: "ok" | "error" | null; readonly minDurationMs?: number | null; readonly lookbackMinutes?: number; readonly limit?: number; readonly attributeFilters?: Readonly<Record<string, string>>; readonly aiText?: string | null; readonly cursorStartedAtMs?: number; readonly cursorTraceId?: string }) => Effect.Effect<readonly TraceSummaryItem[], Error>
|
|
16
|
+
readonly listFacets: (input: { readonly type: "traces" | "logs"; readonly field: string; readonly serviceName?: string | null; readonly key?: string | null; readonly lookbackMinutes?: number; readonly limit?: number }) => Effect.Effect<readonly { readonly value: string; readonly count: number }[], Error>
|
|
17
|
+
readonly searchTraces: (input: { readonly serviceName?: string | null; readonly operation?: string | null; readonly status?: "ok" | "error" | null; readonly minDurationMs?: number | null; readonly lookbackMinutes?: number; readonly limit?: number; readonly attributeFilters?: Readonly<Record<string, string>> }) => Effect.Effect<readonly TraceItem[], Error>
|
|
18
|
+
readonly traceStats: (input: { readonly groupBy: string; readonly agg: "count" | "avg_duration" | "p95_duration" | "error_rate"; readonly serviceName?: string | null; readonly operation?: string | null; readonly status?: "ok" | "error" | null; readonly minDurationMs?: number | null; readonly lookbackMinutes?: number; readonly limit?: number; readonly attributeFilters?: Readonly<Record<string, string>> }) => Effect.Effect<readonly { readonly group: string; readonly value: number; readonly count: number }[], Error>
|
|
19
|
+
readonly getTrace: (traceId: string) => Effect.Effect<TraceItem | null, Error>
|
|
20
|
+
readonly getSpan: (spanId: string) => Effect.Effect<SpanItem | null, Error>
|
|
21
|
+
readonly getAiCall: (spanId: string) => Effect.Effect<AiCallDetail | null, Error>
|
|
22
|
+
readonly listTraceSpans: (traceId: string) => Effect.Effect<readonly SpanItem[], Error>
|
|
23
|
+
readonly searchSpans: (input: { readonly serviceName?: string | null; readonly traceId?: string | null; readonly operation?: string | null; readonly parentOperation?: string | null; readonly status?: "ok" | "error" | null; readonly lookbackMinutes?: number; readonly limit?: number; readonly attributeFilters?: Readonly<Record<string, string>>; readonly attributeContainsFilters?: Readonly<Record<string, string>> }) => Effect.Effect<readonly SpanItem[], Error>
|
|
24
|
+
}
|
|
25
|
+
>()("motel/TraceQueryService") {}
|
|
26
|
+
|
|
27
|
+
export const TraceQueryServiceLive = Layer.effect(
|
|
28
|
+
TraceQueryService,
|
|
29
|
+
Effect.map(TelemetryStore, (store) => TraceQueryService.of({
|
|
30
|
+
listServices: store.listServices,
|
|
31
|
+
listRecentTraces: store.listRecentTraces,
|
|
32
|
+
listTraceSummaries: store.listTraceSummaries,
|
|
33
|
+
searchTraceSummaries: store.searchTraceSummaries,
|
|
34
|
+
listFacets: store.listFacets,
|
|
35
|
+
searchTraces: store.searchTraces,
|
|
36
|
+
traceStats: store.traceStats,
|
|
37
|
+
getTrace: store.getTrace,
|
|
38
|
+
getSpan: store.getSpan,
|
|
39
|
+
getAiCall: store.getAiCall,
|
|
40
|
+
listTraceSpans: store.listTraceSpans,
|
|
41
|
+
searchSpans: store.searchSpans,
|
|
42
|
+
})),
|
|
43
|
+
)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RPC contract for OTLP ingest. Lives in its own file so both the
|
|
3
|
+
* main thread (client) and the telemetry worker (server) can import
|
|
4
|
+
* the schema without pulling in each other's runtime code.
|
|
5
|
+
*
|
|
6
|
+
* Only ingestTraces and ingestLogs run through RPC — those are the
|
|
7
|
+
* methods whose SQLite writes used to block the main event loop for
|
|
8
|
+
* seconds at a time. Read-only TelemetryStore methods use the separate
|
|
9
|
+
* query RPC worker so no synchronous SQLite call runs on the HTTP thread.
|
|
10
|
+
*
|
|
11
|
+
* Payloads are typed as Schema.Unknown because OTLP's protobuf-JSON
|
|
12
|
+
* shape is enormous and nested — the store validates structurally
|
|
13
|
+
* during the actual insert loop, and serialising a schema through
|
|
14
|
+
* the worker boundary would add overhead that beats the purpose of
|
|
15
|
+
* the offload. If a payload is malformed we surface it as an
|
|
16
|
+
* IngestError rather than a RpcSchemaError, which keeps the failure
|
|
17
|
+
* mode consistent with the old direct-call behaviour.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { Schema } from "effect"
|
|
21
|
+
import * as Rpc from "effect/unstable/rpc/Rpc"
|
|
22
|
+
import * as RpcGroup from "effect/unstable/rpc/RpcGroup"
|
|
23
|
+
|
|
24
|
+
export class IngestError extends Schema.TaggedErrorClass<IngestError>()("IngestError", {
|
|
25
|
+
message: Schema.String,
|
|
26
|
+
}) {}
|
|
27
|
+
|
|
28
|
+
export const IngestRpcs = RpcGroup.make(
|
|
29
|
+
Rpc.make("ingestTraces", {
|
|
30
|
+
payload: { payload: Schema.Unknown },
|
|
31
|
+
success: Schema.Struct({ insertedSpans: Schema.Number }),
|
|
32
|
+
error: IngestError,
|
|
33
|
+
}),
|
|
34
|
+
Rpc.make("ingestLogs", {
|
|
35
|
+
payload: { payload: Schema.Unknown },
|
|
36
|
+
success: Schema.Struct({ insertedLogs: Schema.Number }),
|
|
37
|
+
error: IngestError,
|
|
38
|
+
}),
|
|
39
|
+
)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Schema } from "effect"
|
|
2
|
+
import * as Rpc from "effect/unstable/rpc/Rpc"
|
|
3
|
+
import * as RpcGroup from "effect/unstable/rpc/RpcGroup"
|
|
4
|
+
|
|
5
|
+
export class QueryError extends Schema.TaggedErrorClass<QueryError>()("QueryError", {
|
|
6
|
+
message: Schema.String,
|
|
7
|
+
}) {}
|
|
8
|
+
|
|
9
|
+
export const QueryRpcs = RpcGroup.make(
|
|
10
|
+
Rpc.make("query", {
|
|
11
|
+
payload: { method: Schema.String, args: Schema.Array(Schema.Unknown) },
|
|
12
|
+
success: Schema.Any,
|
|
13
|
+
error: QueryError,
|
|
14
|
+
}),
|
|
15
|
+
)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { BunRuntime } from "@effect/platform-bun"
|
|
2
|
+
import * as BunWorkerRunner from "@effect/platform-bun/BunWorkerRunner"
|
|
3
|
+
import { Effect, Layer } from "effect"
|
|
4
|
+
import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization"
|
|
5
|
+
import * as RpcServer from "effect/unstable/rpc/RpcServer"
|
|
6
|
+
import { TelemetryStoreQueryWorkerLive, TelemetryStoreReadonly, type TelemetryStoreReader } from "./TelemetryStore.js"
|
|
7
|
+
import { QueryError, QueryRpcs } from "./queryRpc.js"
|
|
8
|
+
|
|
9
|
+
type QueryMethod = keyof TelemetryStoreReader
|
|
10
|
+
|
|
11
|
+
const QueryHandlers = QueryRpcs.toLayer(Effect.gen(function*() {
|
|
12
|
+
const store = yield* TelemetryStoreReadonly
|
|
13
|
+
return {
|
|
14
|
+
query: ({ method, args }) => {
|
|
15
|
+
const member = Reflect.get(store, method as QueryMethod) as unknown
|
|
16
|
+
const result = typeof member === "function" ? Reflect.apply(member, store, args) : member
|
|
17
|
+
return (result as Effect.Effect<unknown, Error>).pipe(
|
|
18
|
+
Effect.mapError((error) => new QueryError({ message: String(error) })),
|
|
19
|
+
)
|
|
20
|
+
},
|
|
21
|
+
}
|
|
22
|
+
}))
|
|
23
|
+
|
|
24
|
+
const WorkerLive = RpcServer.layer(QueryRpcs).pipe(
|
|
25
|
+
Layer.provide(QueryHandlers),
|
|
26
|
+
Layer.provide(TelemetryStoreQueryWorkerLive),
|
|
27
|
+
Layer.provide(RpcServer.layerProtocolWorkerRunner),
|
|
28
|
+
Layer.provide(RpcSerialization.layerMsgPack),
|
|
29
|
+
Layer.provide(BunWorkerRunner.layer),
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
Layer.launch(WorkerLive).pipe(BunRuntime.runMain)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worker-thread entry point for OTLP ingest.
|
|
3
|
+
*
|
|
4
|
+
* Spawned by the main process via `new Worker(new URL("./telemetryWorker.ts", import.meta.url))`.
|
|
5
|
+
* This file runs inside a Bun Worker, so anything it imports is
|
|
6
|
+
* evaluated in a FRESH module graph on the worker side. In particular
|
|
7
|
+
* `TelemetryStoreWorkerLive` opens its own SQLite connection here — the main
|
|
8
|
+
* thread's store connection is unrelated. SQLite's WAL journal mode
|
|
9
|
+
* lets writer and query-worker connections coexist against the same
|
|
10
|
+
* `.sqlite` file without blocking the HTTP event loop.
|
|
11
|
+
*
|
|
12
|
+
* The worker exposes only `ingestTraces` / `ingestLogs` (see ingestRpc.ts)
|
|
13
|
+
* and owns writer maintenance. Read queries run in telemetryQueryWorker.ts;
|
|
14
|
+
* neither path can block the HTTP event loop.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { BunRuntime } from "@effect/platform-bun"
|
|
18
|
+
import * as BunWorkerRunner from "@effect/platform-bun/BunWorkerRunner"
|
|
19
|
+
import { Effect, Layer } from "effect"
|
|
20
|
+
import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization"
|
|
21
|
+
import * as RpcServer from "effect/unstable/rpc/RpcServer"
|
|
22
|
+
import type { OtlpLogExportRequest, OtlpTraceExportRequest } from "../otlp.ts"
|
|
23
|
+
import { IngestError, IngestRpcs } from "./ingestRpc.ts"
|
|
24
|
+
import { TelemetryStore, TelemetryStoreWorkerLive } from "./TelemetryStore.ts"
|
|
25
|
+
|
|
26
|
+
// Wire the two RPC methods to the existing TelemetryStore service.
|
|
27
|
+
// The store's ingest methods already carry their own Effect.fn spans,
|
|
28
|
+
// so the worker-side traces show up correctly attributed — the RPC
|
|
29
|
+
// framework also auto-spans each incoming request with method +
|
|
30
|
+
// payload-size attributes, giving us visibility into how ingest is
|
|
31
|
+
// splitting its time across the queue / wire / SQL stages.
|
|
32
|
+
const IngestHandlers = IngestRpcs.toLayer(
|
|
33
|
+
Effect.gen(function*() {
|
|
34
|
+
const store = yield* TelemetryStore
|
|
35
|
+
return {
|
|
36
|
+
ingestTraces: ({ payload }) =>
|
|
37
|
+
store.ingestTraces(payload as OtlpTraceExportRequest).pipe(
|
|
38
|
+
Effect.mapError((cause) => new IngestError({ message: String(cause) })),
|
|
39
|
+
),
|
|
40
|
+
ingestLogs: ({ payload }) =>
|
|
41
|
+
store.ingestLogs(payload as OtlpLogExportRequest).pipe(
|
|
42
|
+
Effect.mapError((cause) => new IngestError({ message: String(cause) })),
|
|
43
|
+
),
|
|
44
|
+
}
|
|
45
|
+
}),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
const WorkerLive = RpcServer.layer(IngestRpcs).pipe(
|
|
49
|
+
Layer.provide(IngestHandlers),
|
|
50
|
+
Layer.provide(TelemetryStoreWorkerLive),
|
|
51
|
+
Layer.provide(RpcServer.layerProtocolWorkerRunner),
|
|
52
|
+
Layer.provide(RpcSerialization.layerMsgPack),
|
|
53
|
+
Layer.provide(BunWorkerRunner.layer),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
// BunRuntime.runMain installs signal handlers so the scope closes
|
|
57
|
+
// cleanly on termination; the BunHttpServer layer pattern from the
|
|
58
|
+
// main server carries over here.
|
|
59
|
+
Layer.launch(WorkerLive).pipe(BunRuntime.runMain)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const EPOCH_KEY = "__motelStartupEpoch"
|
|
2
|
+
|
|
3
|
+
type GlobalWithStartupEpoch = typeof globalThis & {
|
|
4
|
+
[EPOCH_KEY]?: number
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const globalWithEpoch = globalThis as GlobalWithStartupEpoch
|
|
8
|
+
|
|
9
|
+
if (globalWithEpoch[EPOCH_KEY] === undefined) {
|
|
10
|
+
globalWithEpoch[EPOCH_KEY] = performance.now()
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const startupBenchEnabled = process.env.MOTEL_BENCH_STARTUP_PHASES === "1"
|
|
14
|
+
|
|
15
|
+
export const startupBenchMark = (phase: string) => {
|
|
16
|
+
if (!startupBenchEnabled) return
|
|
17
|
+
const elapsedMs = performance.now() - (globalWithEpoch[EPOCH_KEY] ?? performance.now())
|
|
18
|
+
process.stderr.write(`[motel-startup] ${phase} ${elapsedMs.toFixed(3)}ms\n`)
|
|
19
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Effect } from "effect"
|
|
2
|
+
import { createDaemonManager, type DaemonStatus } from "./daemon.js"
|
|
3
|
+
import { createLaunchAgentManager, createMotelLifecycle, usesLaunchAgentRuntime, type LaunchAgentManager, type MotelLifecycle } from "./launchAgent.js"
|
|
4
|
+
import { MOTEL_SERVICE_ID } from "./registry.js"
|
|
5
|
+
|
|
6
|
+
export type ConflictStatus = DaemonStatus & {
|
|
7
|
+
readonly service: typeof MOTEL_SERVICE_ID
|
|
8
|
+
readonly pid: number
|
|
9
|
+
readonly workdir: string
|
|
10
|
+
readonly reason: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const startDaemon = (lifecycle: MotelLifecycle = createMotelLifecycle()) => Effect.runPromise(lifecycle.start)
|
|
14
|
+
|
|
15
|
+
const parsePort = (url: string) => {
|
|
16
|
+
try {
|
|
17
|
+
const port = Number(new URL(url).port)
|
|
18
|
+
return Number.isFinite(port) && port > 0 ? port : undefined
|
|
19
|
+
} catch {
|
|
20
|
+
return undefined
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const stopConflictingDaemon = async (
|
|
25
|
+
status: ConflictStatus,
|
|
26
|
+
options: {
|
|
27
|
+
readonly service?: LaunchAgentManager
|
|
28
|
+
readonly createManager?: typeof createDaemonManager
|
|
29
|
+
} = {},
|
|
30
|
+
) => {
|
|
31
|
+
const service = options.service ?? createLaunchAgentManager()
|
|
32
|
+
if (usesLaunchAgentRuntime() && service.available) {
|
|
33
|
+
const serviceStatus = await Effect.runPromise(service.status).catch((error) => {
|
|
34
|
+
throw new Error(`Refusing detached recovery until Motel LaunchAgent ownership can be inspected: ${error instanceof Error ? error.message : String(error)}`)
|
|
35
|
+
})
|
|
36
|
+
if (serviceStatus?.manager === "loaded" && serviceStatus.health.pid === status.pid) {
|
|
37
|
+
await Effect.runPromise(service.stop)
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
if (serviceStatus?.manager === "loaded") {
|
|
41
|
+
throw new Error("Refusing detached recovery while Motel LaunchAgent is loaded for a different process.")
|
|
42
|
+
}
|
|
43
|
+
if (serviceStatus?.manager === "unknown") {
|
|
44
|
+
throw new Error("Refusing detached recovery until Motel LaunchAgent ownership can be inspected reliably.")
|
|
45
|
+
}
|
|
46
|
+
if (serviceStatus?.configuration !== "missing") {
|
|
47
|
+
throw new Error("Refusing detached recovery while a Motel LaunchAgent definition is present.")
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const port = parsePort(status.url)
|
|
51
|
+
const manager = (options.createManager ?? createDaemonManager)({
|
|
52
|
+
workdir: status.workdir ?? undefined,
|
|
53
|
+
databasePath: status.databasePath,
|
|
54
|
+
port,
|
|
55
|
+
})
|
|
56
|
+
await Effect.runPromise(manager.stop)
|
|
57
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
// Storybook-style preview for AiChatView. Renders the component
|
|
2
|
+
// against a menu of fixtures so we can iterate on the rendering
|
|
3
|
+
// without needing real LLM traffic captured.
|
|
4
|
+
//
|
|
5
|
+
// Run it: `bun run story:chat`
|
|
6
|
+
// Keys:
|
|
7
|
+
// 1..N switch fixture
|
|
8
|
+
// j / k scroll down / up (1 line)
|
|
9
|
+
// ctrl-d/u half-page
|
|
10
|
+
// gg / G jump to top / bottom
|
|
11
|
+
// r force re-render with a fresh date (to sanity-check headers)
|
|
12
|
+
// q / ^c quit
|
|
13
|
+
|
|
14
|
+
import { RegistryProvider } from "@effect/atom-react"
|
|
15
|
+
import { RGBA, TextAttributes, createCliRenderer } from "@opentui/core"
|
|
16
|
+
import { createRoot, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"
|
|
17
|
+
import { useEffect, useMemo, useRef, useState } from "react"
|
|
18
|
+
import { buildChunks, type Chunk } from "../ui/aiChatModel.ts"
|
|
19
|
+
import { AiChatView } from "../ui/AiChatView.tsx"
|
|
20
|
+
import { Divider, TextLine } from "../ui/primitives.tsx"
|
|
21
|
+
import type { AiCallDetailState } from "../ui/aiState.ts"
|
|
22
|
+
import { applyTheme, colors, SEPARATOR } from "../ui/theme.ts"
|
|
23
|
+
import type { ChatFixture } from "./fixtures/index.ts"
|
|
24
|
+
import { errorFixture } from "./fixtures/errorState.ts"
|
|
25
|
+
import { imagePasteFixture } from "./fixtures/imagePaste.ts"
|
|
26
|
+
import { kitchenSinkFixture } from "./fixtures/kitchenSink.ts"
|
|
27
|
+
import { rawPromptFixture } from "./fixtures/rawPrompt.ts"
|
|
28
|
+
import { shortFixture } from "./fixtures/short.ts"
|
|
29
|
+
import { toolHeavyFixture } from "./fixtures/toolHeavy.ts"
|
|
30
|
+
|
|
31
|
+
// Kitchen-sink first so launching the story lands on something that
|
|
32
|
+
// shows every rendering branch at once. The other fixtures exercise
|
|
33
|
+
// one case at a time for regression testing.
|
|
34
|
+
const FIXTURES: readonly ChatFixture[] = [
|
|
35
|
+
kitchenSinkFixture,
|
|
36
|
+
shortFixture,
|
|
37
|
+
toolHeavyFixture,
|
|
38
|
+
imagePasteFixture,
|
|
39
|
+
rawPromptFixture,
|
|
40
|
+
errorFixture,
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
const HEADER_ROWS = 2
|
|
44
|
+
const FOOTER_ROWS = 1
|
|
45
|
+
|
|
46
|
+
const StoryApp = () => {
|
|
47
|
+
applyTheme("motel-default")
|
|
48
|
+
const renderer = useRenderer()
|
|
49
|
+
const { width, height } = useTerminalDimensions()
|
|
50
|
+
const w = width ?? 120
|
|
51
|
+
const h = height ?? 32
|
|
52
|
+
const [fixtureIdx, setFixtureIdx] = useState(0)
|
|
53
|
+
const [selectedChunkId, setSelectedChunkId] = useState<string | null>(null)
|
|
54
|
+
const [detailChunkId, setDetailChunkId] = useState<string | null>(null)
|
|
55
|
+
const [detailScrollOffset, setDetailScrollOffset] = useState(0)
|
|
56
|
+
const pendingGRef = useRef(false)
|
|
57
|
+
const quittingRef = useRef(false)
|
|
58
|
+
|
|
59
|
+
const fixture = FIXTURES[fixtureIdx] ?? FIXTURES[0]!
|
|
60
|
+
|
|
61
|
+
const detailState: AiCallDetailState = useMemo(() => ({
|
|
62
|
+
status: "ready",
|
|
63
|
+
spanId: fixture.span.spanId,
|
|
64
|
+
data: fixture.detail,
|
|
65
|
+
error: null,
|
|
66
|
+
}), [fixture])
|
|
67
|
+
|
|
68
|
+
const chunks = useMemo<readonly Chunk[]>(() => buildChunks(fixture.detail), [fixture])
|
|
69
|
+
|
|
70
|
+
// Reset selection + expansion whenever fixture changes.
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
setSelectedChunkId(chunks[0]?.id ?? null)
|
|
73
|
+
setDetailChunkId(null)
|
|
74
|
+
setDetailScrollOffset(0)
|
|
75
|
+
}, [fixtureIdx, chunks])
|
|
76
|
+
|
|
77
|
+
const move = (delta: number) => {
|
|
78
|
+
if (chunks.length === 0) return
|
|
79
|
+
const idx = selectedChunkId ? chunks.findIndex((c) => c.id === selectedChunkId) : 0
|
|
80
|
+
const next = chunks[Math.max(0, Math.min(idx + delta, chunks.length - 1))]
|
|
81
|
+
if (next) setSelectedChunkId(next.id)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
useKeyboard((key) => {
|
|
85
|
+
if (key.name === "q" || (key.ctrl && key.name === "c")) {
|
|
86
|
+
// renderer.destroy() runs the opentui teardown (disables
|
|
87
|
+
// mouse tracking / kitty keyboard / bracketed paste / alt
|
|
88
|
+
// screen) before onDestroy exits the process. Bypassing it
|
|
89
|
+
// with a raw process.exit leaks those escape sequences into
|
|
90
|
+
// the host shell.
|
|
91
|
+
if (quittingRef.current) return
|
|
92
|
+
quittingRef.current = true
|
|
93
|
+
renderer.destroy()
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
if (/^[1-9]$/.test(key.name) && !key.ctrl && !key.meta) {
|
|
97
|
+
const idx = parseInt(key.name, 10) - 1
|
|
98
|
+
if (idx < FIXTURES.length) setFixtureIdx(idx)
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
if (key.name === "j" || key.name === "down") {
|
|
102
|
+
if (detailChunkId) setDetailScrollOffset((current) => current + 1)
|
|
103
|
+
else move(1)
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
if (key.name === "k" || key.name === "up") {
|
|
107
|
+
if (detailChunkId) setDetailScrollOffset((current) => Math.max(0, current - 1))
|
|
108
|
+
else move(-1)
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
if (key.name === "pagedown" || (key.ctrl && key.name === "d")) {
|
|
112
|
+
if (detailChunkId) setDetailScrollOffset((current) => current + Math.max(1, Math.floor(bodyLines / 2)))
|
|
113
|
+
else move(Math.max(1, Math.floor(chunks.length / 4)))
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
if (key.name === "pageup" || (key.ctrl && key.name === "u")) {
|
|
117
|
+
if (detailChunkId) setDetailScrollOffset((current) => Math.max(0, current - Math.max(1, Math.floor(bodyLines / 2))))
|
|
118
|
+
else move(-Math.max(1, Math.floor(chunks.length / 4)))
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
if (key.name === "g" && !key.shift) {
|
|
122
|
+
if (detailChunkId) {
|
|
123
|
+
if (pendingGRef.current) {
|
|
124
|
+
setDetailScrollOffset(0)
|
|
125
|
+
pendingGRef.current = false
|
|
126
|
+
} else {
|
|
127
|
+
pendingGRef.current = true
|
|
128
|
+
setTimeout(() => { pendingGRef.current = false }, 500)
|
|
129
|
+
}
|
|
130
|
+
return
|
|
131
|
+
}
|
|
132
|
+
if (pendingGRef.current) {
|
|
133
|
+
if (chunks[0]) setSelectedChunkId(chunks[0].id)
|
|
134
|
+
pendingGRef.current = false
|
|
135
|
+
} else {
|
|
136
|
+
pendingGRef.current = true
|
|
137
|
+
setTimeout(() => { pendingGRef.current = false }, 500)
|
|
138
|
+
}
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
if (key.name === "g" && key.shift) {
|
|
142
|
+
if (detailChunkId) setDetailScrollOffset(999999)
|
|
143
|
+
else {
|
|
144
|
+
const last = chunks[chunks.length - 1]
|
|
145
|
+
if (last) setSelectedChunkId(last.id)
|
|
146
|
+
}
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
if (key.name === "escape") {
|
|
150
|
+
setDetailChunkId(null)
|
|
151
|
+
setDetailScrollOffset(0)
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
if (key.name === "return" || key.name === "enter") {
|
|
155
|
+
const chunk = chunks.find((c) => c.id === selectedChunkId)
|
|
156
|
+
if (chunk) {
|
|
157
|
+
setDetailChunkId(chunk.id)
|
|
158
|
+
setDetailScrollOffset(0)
|
|
159
|
+
}
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
const bodyLines = Math.max(4, h - HEADER_ROWS - FOOTER_ROWS - 4 /* AI_CHAT_HEADER_ROWS */)
|
|
165
|
+
|
|
166
|
+
// Short compact labels — a single TextLine truncates with "..." if
|
|
167
|
+
// it overflows the padded content width, so we keep labels tight and
|
|
168
|
+
// use a single separator between items.
|
|
169
|
+
const fixtureList = FIXTURES.map((f, i) => (
|
|
170
|
+
<span key={f.id}>
|
|
171
|
+
<span fg={i === fixtureIdx ? colors.accent : colors.muted} attributes={i === fixtureIdx ? TextAttributes.BOLD : undefined}>
|
|
172
|
+
{`${i + 1} ${f.label}`}
|
|
173
|
+
</span>
|
|
174
|
+
{i < FIXTURES.length - 1 ? <span fg={colors.separator}>{" \u00b7 "}</span> : null}
|
|
175
|
+
</span>
|
|
176
|
+
))
|
|
177
|
+
|
|
178
|
+
// Header + divider live inside a paddingLeft/Right={1} box, so the
|
|
179
|
+
// real content width is `w - 2`. Divider must match or we get a
|
|
180
|
+
// mid-line "..." truncation.
|
|
181
|
+
const contentWidth = Math.max(8, w - 2)
|
|
182
|
+
|
|
183
|
+
return (
|
|
184
|
+
<box width={w} height={h} flexDirection="column" backgroundColor={RGBA.fromHex(colors.screenBg)}>
|
|
185
|
+
<box paddingLeft={1} paddingRight={1} height={HEADER_ROWS} flexDirection="column">
|
|
186
|
+
<TextLine>
|
|
187
|
+
<span fg={colors.muted} attributes={TextAttributes.BOLD}>AI CHAT</span>
|
|
188
|
+
<span fg={colors.separator}>{" \u00b7 "}</span>
|
|
189
|
+
{fixtureList}
|
|
190
|
+
</TextLine>
|
|
191
|
+
<Divider width={contentWidth} />
|
|
192
|
+
</box>
|
|
193
|
+
<AiChatView
|
|
194
|
+
span={fixture.span}
|
|
195
|
+
detailState={detailState}
|
|
196
|
+
chunks={chunks}
|
|
197
|
+
selectedChunkId={selectedChunkId}
|
|
198
|
+
onSelectChunk={(chunkId) => setSelectedChunkId(chunkId)}
|
|
199
|
+
detailChunkId={detailChunkId}
|
|
200
|
+
onOpenDetail={(chunkId) => {
|
|
201
|
+
setSelectedChunkId(chunkId)
|
|
202
|
+
setDetailChunkId(chunkId)
|
|
203
|
+
setDetailScrollOffset(0)
|
|
204
|
+
}}
|
|
205
|
+
onCloseDetail={() => {
|
|
206
|
+
setDetailChunkId(null)
|
|
207
|
+
setDetailScrollOffset(0)
|
|
208
|
+
}}
|
|
209
|
+
detailScrollOffset={detailScrollOffset}
|
|
210
|
+
onSetDetailScrollOffset={(updater) => setDetailScrollOffset(updater)}
|
|
211
|
+
contentWidth={Math.max(24, w - 2)}
|
|
212
|
+
bodyLines={bodyLines}
|
|
213
|
+
paneWidth={w}
|
|
214
|
+
/>
|
|
215
|
+
<Divider width={contentWidth} />
|
|
216
|
+
<box paddingLeft={1} paddingRight={1} height={FOOTER_ROWS}>
|
|
217
|
+
<TextLine>
|
|
218
|
+
<span fg={colors.count} attributes={TextAttributes.BOLD}>1-9</span>
|
|
219
|
+
<span fg={colors.muted}>{" fixture "}</span>
|
|
220
|
+
<span fg={colors.count} attributes={TextAttributes.BOLD}>j/k</span>
|
|
221
|
+
<span fg={colors.muted}>{" move "}</span>
|
|
222
|
+
<span fg={colors.count} attributes={TextAttributes.BOLD}>enter</span>
|
|
223
|
+
<span fg={colors.muted}>{" detail "}</span>
|
|
224
|
+
<span fg={colors.count} attributes={TextAttributes.BOLD}>gg/G</span>
|
|
225
|
+
<span fg={colors.muted}>{" top/bottom "}</span>
|
|
226
|
+
<span fg={colors.count} attributes={TextAttributes.BOLD}>q</span>
|
|
227
|
+
<span fg={colors.muted}>{" quit"}</span>
|
|
228
|
+
</TextLine>
|
|
229
|
+
</box>
|
|
230
|
+
</box>
|
|
231
|
+
)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const renderer = await createCliRenderer({
|
|
235
|
+
exitOnCtrlC: false,
|
|
236
|
+
screenMode: "alternate-screen",
|
|
237
|
+
onDestroy: () => { process.exit(0) },
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
createRoot(renderer).render(
|
|
241
|
+
<RegistryProvider>
|
|
242
|
+
<StoryApp />
|
|
243
|
+
</RegistryProvider>,
|
|
244
|
+
)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { type ChatFixture, makeDetail, makeSpan } from "./index.ts"
|
|
2
|
+
|
|
3
|
+
// A tool-call that errored mid-stream: finishReason === "error" and a
|
|
4
|
+
// tool-result whose output shape is irregular (object without the
|
|
5
|
+
// expected `type: "text"` wrapper). Covers graceful-degrade rendering.
|
|
6
|
+
export const errorFixture: ChatFixture = {
|
|
7
|
+
id: "error",
|
|
8
|
+
label: "error",
|
|
9
|
+
span: makeSpan({ status: "error", durationMs: 820 }),
|
|
10
|
+
detail: makeDetail({
|
|
11
|
+
status: "error",
|
|
12
|
+
finishReason: "error",
|
|
13
|
+
durationMs: 820,
|
|
14
|
+
promptMessages: {
|
|
15
|
+
messages: [
|
|
16
|
+
{ role: "user", content: [{ type: "text", text: "Grab the latest commits from main." }] },
|
|
17
|
+
{
|
|
18
|
+
role: "assistant",
|
|
19
|
+
content: [
|
|
20
|
+
{ type: "tool-call", toolCallId: "tc-1", toolName: "bash", input: { command: "git fetch origin main && git log --oneline -5 origin/main" } },
|
|
21
|
+
],
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
role: "tool",
|
|
25
|
+
content: [
|
|
26
|
+
{
|
|
27
|
+
type: "tool-result",
|
|
28
|
+
toolCallId: "tc-1",
|
|
29
|
+
toolName: "bash",
|
|
30
|
+
output: { error: "HTTP 429 rate limited", code: 429, retryable: true },
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
role: "assistant",
|
|
36
|
+
content: [
|
|
37
|
+
{ type: "text", text: "The request was rate-limited. I'll retry after a delay — or you can rerun manually." },
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
responseText: null,
|
|
43
|
+
}),
|
|
44
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type ChatFixture, makeDetail, makeSpan } from "./index.ts"
|
|
2
|
+
|
|
3
|
+
// User pastes an image as a data URL alongside text. The renderer
|
|
4
|
+
// should replace the base64 blob with a compact `[data:image/png
|
|
5
|
+
// base64 NNk]` marker and leave the prose readable. Regression case
|
|
6
|
+
// for the "screen filled with base64" bug.
|
|
7
|
+
const base64Chunk = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAD".repeat(1000)
|
|
8
|
+
|
|
9
|
+
export const imagePasteFixture: ChatFixture = {
|
|
10
|
+
id: "image-paste",
|
|
11
|
+
label: "image",
|
|
12
|
+
span: makeSpan(),
|
|
13
|
+
detail: makeDetail({
|
|
14
|
+
promptMessages: {
|
|
15
|
+
messages: [
|
|
16
|
+
{ role: "system", content: "You are a code review assistant." },
|
|
17
|
+
{
|
|
18
|
+
role: "user",
|
|
19
|
+
content: [
|
|
20
|
+
{ type: "text", text: `I captured this screenshot of the bug: data:image/png;base64,${base64Chunk}\n\nCan you tell me what's off about the formatting here?` },
|
|
21
|
+
],
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
role: "assistant",
|
|
25
|
+
content: [
|
|
26
|
+
{ type: "text", text: "Based on the image, the top padding looks inconsistent between rows. Let me open the component to check." },
|
|
27
|
+
{ type: "tool-call", toolCallId: "tc-1", toolName: "read", input: { filePath: "/src/ui/Row.tsx" } },
|
|
28
|
+
],
|
|
29
|
+
},
|
|
30
|
+
],
|
|
31
|
+
},
|
|
32
|
+
responseText: "The padding comes from two different tokens; we should unify them.",
|
|
33
|
+
}),
|
|
34
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Shared fixture type for the AI-chat storybook. Each fixture is a
|
|
2
|
+
// label + a synthetic span + a synthetic AI call detail, in the same
|
|
3
|
+
// shape the real server returns. Prefer hand-crafted minimal examples
|
|
4
|
+
// over real captures — they exercise renderer cases deliberately and
|
|
5
|
+
// don't leak session content into the repo.
|
|
6
|
+
|
|
7
|
+
import type { AiCallDetail, TraceSpanItem } from "../../domain.ts"
|
|
8
|
+
|
|
9
|
+
export interface ChatFixture {
|
|
10
|
+
readonly id: string
|
|
11
|
+
readonly label: string
|
|
12
|
+
readonly span: TraceSpanItem
|
|
13
|
+
readonly detail: AiCallDetail
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const makeSpan = (overrides: Partial<TraceSpanItem> = {}): TraceSpanItem => ({
|
|
17
|
+
spanId: "fixture-span-0000",
|
|
18
|
+
parentSpanId: null,
|
|
19
|
+
operationName: "ai.streamText",
|
|
20
|
+
serviceName: "storybook",
|
|
21
|
+
scopeName: "ai",
|
|
22
|
+
kind: "internal",
|
|
23
|
+
status: "ok",
|
|
24
|
+
startTime: new Date(),
|
|
25
|
+
durationMs: 2400,
|
|
26
|
+
isRunning: false,
|
|
27
|
+
depth: 0,
|
|
28
|
+
tags: {
|
|
29
|
+
"ai.operationId": "ai.streamText",
|
|
30
|
+
"ai.model.id": "claude-opus-4-7",
|
|
31
|
+
"ai.model.provider": "anthropic",
|
|
32
|
+
"ai.prompt.messages": "[]",
|
|
33
|
+
},
|
|
34
|
+
warnings: [],
|
|
35
|
+
events: [],
|
|
36
|
+
...overrides,
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
export const makeDetail = (overrides: Partial<AiCallDetail> = {}): AiCallDetail => ({
|
|
40
|
+
traceId: "fixture-trace-0000",
|
|
41
|
+
spanId: "fixture-span-0000",
|
|
42
|
+
operation: "streamText",
|
|
43
|
+
service: "storybook",
|
|
44
|
+
functionId: "story.demo",
|
|
45
|
+
provider: "anthropic",
|
|
46
|
+
model: "claude-opus-4-7",
|
|
47
|
+
status: "ok",
|
|
48
|
+
startedAt: new Date().toISOString(),
|
|
49
|
+
durationMs: 2400,
|
|
50
|
+
sessionId: "ses_fixture",
|
|
51
|
+
userId: "kit",
|
|
52
|
+
finishReason: "stop",
|
|
53
|
+
promptMessages: null,
|
|
54
|
+
responseText: null,
|
|
55
|
+
toolCalls: [],
|
|
56
|
+
toolsAvailable: null,
|
|
57
|
+
providerMetadata: null,
|
|
58
|
+
usage: { inputTokens: 1234, outputTokens: 321, totalTokens: 1555, cachedInputTokens: 0, reasoningTokens: null },
|
|
59
|
+
timing: { msToFirstChunk: null, msToFinish: null, avgOutputTokensPerSecond: null },
|
|
60
|
+
logs: [],
|
|
61
|
+
...overrides,
|
|
62
|
+
})
|