Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions cli/templates/features/auth/files/lib/jwt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,14 @@ function base64UrlEncode(bytes: ArrayBuffer): string {
.replace(/=+$/, "");
}

function base64UrlDecodeToBytes(input: string): Uint8Array {
function base64UrlDecodeToBytes(input: string): Uint8Array<ArrayBuffer> {
return Uint8Array.from(
atob(input.replace(/-/g, "+").replace(/_/g, "/")),
(c) => c.charCodeAt(0),
);
}

function encodeText(input: string): Uint8Array {
function encodeText(input: string): Uint8Array<ArrayBuffer> {
return new TextEncoder().encode(input);
}

Expand Down
19 changes: 12 additions & 7 deletions cli/test-utils/deploy-test-support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@

import { assertEquals } from "veryfront/testing/assert";
import { _resetEnvironmentConfig } from "#veryfront/config/environment-config.ts";
import type { ReleaseAssetManifestResponse } from "veryfront/release-assets";
import {
RELEASE_ASSET_BASE_PATH,
RELEASE_ASSET_CONTENT_TYPES,
RELEASE_ASSET_MANIFEST_SCHEMA_VERSION,
type ReleaseAssetManifestResponse,
} from "veryfront/release-assets";
import { computeSourceDigest, writePushReceipt } from "../shared/deployment-provenance.ts";
import type {
DeployControlPlane,
Expand Down Expand Up @@ -134,26 +139,26 @@ export function readyManifest(routes: Record<string, { modules: string[]; css: s
state: "ready",
manifest_version: 1,
manifest: {
schemaVersion: 1,
schemaVersion: RELEASE_ASSET_MANIFEST_SCHEMA_VERSION,
projectId: PROJECT_ID,
releaseId: "release-1",
releaseVersion: 1,
manifestVersion: 1,
builderVersion: "test",
sourceContentHash: "sha256:test",
sourceContentHash: "a".repeat(64),
createdAt: "2026-07-30T00:00:00.000Z",
assetBasePath: "/_vf/assets/release-1/",
assetBasePath: RELEASE_ASSET_BASE_PATH,
modules: {
"app/page.js": {
contentHash: "sha256:module",
contentHash: "b".repeat(64),
size: 12,
contentType: "application/javascript",
contentType: RELEASE_ASSET_CONTENT_TYPES.js,
},
},
css: [],
routes,
dependencyMode: "immutable",
dependencies: {},
fallback: { mode: "jit", gaps: [] },
},
};
}
Expand Down
2 changes: 1 addition & 1 deletion deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -519,7 +519,7 @@
"lint:ban-test-only": "deno run --allow-read scripts/lint/ban-test-only.ts",
"lint:sanitizer-baseline": "deno run --allow-read scripts/lint/check-sanitizer-baseline.ts",
"lint:skipped-tests": "deno run --allow-read scripts/lint/check-skipped-tests-baseline.ts",
"test:scripts": "deno test --config=scripts/test.deno.json --no-check --allow-read --allow-write --allow-run scripts/ci/publish-npm-packages.test.ts scripts/build/dnt-polyfill.test.ts scripts/build/generate-sbom.test.ts scripts/build/npm-dependency-sources.test.ts scripts/build/npm-extension-package-metadata.test.ts scripts/build/npm-package-metadata.test.ts scripts/build/npm-react-shims.test.ts scripts/docs/docs-coverage.test.ts scripts/docs/generate-api-reference.test.ts scripts/docs/guide-validation.test.ts scripts/lint/audit-core-deps.test.ts scripts/lint/audit-dependency-boundaries.test.ts scripts/lint/audit-extension-capabilities.test.ts scripts/lint/audit-extension-contracts.test.ts scripts/lint/audit-deps.test.ts scripts/lint/check-module-boundaries.test.ts scripts/lint/ban-test-only.test.ts scripts/lint/check-sanitizer-baseline.test.ts scripts/lint/check-skipped-tests-baseline.test.ts scripts/lint/check-coverage.test.ts scripts/security/audit-npm.test.ts scripts/security/submit-dependency-snapshot.test.ts && deno task test:tool-search-live",
"test:scripts": "deno test --config=scripts/test.deno.json --no-check --allow-read --allow-write --allow-run scripts/ci/publish-npm-packages.test.ts scripts/build/dnt-polyfill.test.ts scripts/build/generate-sbom.test.ts scripts/build/npm-dependency-sources.test.ts scripts/build/npm-extension-package-metadata.test.ts scripts/build/npm-package-metadata.test.ts scripts/build/npm-react-shims.test.ts scripts/docs/docs-coverage.test.ts scripts/docs/generate-api-reference.test.ts scripts/docs/guide-validation.test.ts scripts/lint/audit-core-deps.test.ts scripts/lint/audit-dependency-boundaries.test.ts scripts/lint/audit-extension-capabilities.test.ts scripts/lint/audit-extension-contracts.test.ts scripts/lint/audit-deps.test.ts scripts/lint/check-module-boundaries.test.ts scripts/lint/ban-test-only.test.ts scripts/lint/check-sanitizer-baseline.test.ts scripts/lint/check-skipped-tests-baseline.test.ts scripts/lint/check-test-typecheck-baseline.test.ts scripts/lint/check-coverage.test.ts scripts/security/audit-npm.test.ts scripts/security/submit-dependency-snapshot.test.ts && deno task test:tool-search-live",
"test:sentry-runtime-packages": "deno test --config=scripts/test.deno.json --no-check --no-lock --allow-read --allow-write --allow-run --allow-env=DENO_DIR,HOME,XDG_CACHE_HOME,LOCALAPPDATA,USERPROFILE scripts/build/sentry-runtime-packages.test.ts",
"test:tool-search-live": "VF_DISABLE_LRU_INTERVAL=1 deno test --no-check -A tests/agent/verify-tool-search-live.test.ts",
"test:cross-runtime": "deno run --allow-all src/platform/compat/cross-runtime.test.ts",
Expand Down
122 changes: 122 additions & 0 deletions docs/guides/chat-hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,128 @@ after another tab deletes the conversation. Use IndexedDB or a durable custom
store with revisions, compare-and-set writes, and durable tombstones when the
application must prevent stale overwrites or deletion resurrection.

## Composition hooks

When you compose the chat UI yourself (see [UI + chat](./chat-ui.md)), these
hooks expose the state behind the components. Context-backed hooks must be used
inside their matching provider (`<ChatInput>` / `<Message>` / `<Chat>`), while
`useChatScroll` is standalone and can be used with any scroll container.

### useChatInput

The headless composer. Reads the enclosing `<ChatInput>` context and returns the
input state plus prop-getters you spread onto your own elements. The getters
merge your handlers/classes with the internal ones:

```tsx
"use client";

import { useState } from "react";
import { ChatInput, useChatInput } from "veryfront/chat";

function Fields() {
const input = useChatInput();
return (
<form {...input.getFormProps()}>
<textarea {...input.getFieldProps()} placeholder="Message…" />
<button {...input.getSubmitProps()}>Send</button>
</form>
);
}

export function CustomComposer({
onSend,
}: {
onSend: (message: { text: string }) => void;
}) {
const [value, setValue] = useState("");
return (
<ChatInput.Root
input={value}
onChange={(event) => setValue(event.currentTarget.value)}
sendMessage={onSend}
setInput={setValue}
>
<Fields />
</ChatInput.Root>
);
}
```

Getters: `getFormProps`, `getFieldProps` (for a textarea), `getSubmitProps`, `getAttachProps`,
`getVoiceProps`. State: `input`, `canSubmit`, `canAttach`, `isLoading`, `isListening`,
`canUseVoice`, `attachments`, `model`. Use `canAttach` and `canUseVoice` to omit custom
controls when their corresponding capability is not configured. Their prop getters also
return fail-closed `disabled` state so unavailable controls cannot be re-enabled accidentally.
`mergeProps` is exported for composing several getters onto one element. The preset
`<Chat>` wires `setInput` automatically; direct `<ChatInput>` or `<ChatInput.Root>`
providers must receive `setInput` before a headless child calls `input.setInput(...)`.

### useChatScroll

Stick-to-bottom scroll management for a message list. Attach `scrollRef` to the
viewport and `contentRef` to the growing content; the hook keeps the user pinned
to the bottom while streaming:

```tsx
"use client";

import { type ChatMessage, Message, useChatScroll } from "veryfront/chat";

export default function CustomTranscript(
{ messages }: { messages: ChatMessage[] },
) {
const scroll = useChatScroll<HTMLDivElement>(messages.length);

return (
<div ref={scroll.scrollRef} className="overflow-y-auto">
<div ref={scroll.contentRef}>
{messages.map((message) => <Message key={message.id} message={message} />)}
</div>
</div>
);
}
```

Also: `viewportRef`, `isAtBottom`, `scrollToBottom`/`scrollToEnd`, `scrollToStart`,
`scrollToMessage(id)`, and `getViewportProps()`. (`useStickToBottom` is the old
name, kept as a deprecated alias.)

### useMessageBranches

The regeneration/edit variants of a message (what `BranchPicker` shows). Must be
used inside a `<Message>`:

```tsx
"use client";

import { type ChatMessage, Message, useMessageBranches } from "veryfront/chat";

function BranchButtons() {
const branches = useMessageBranches();
return (
<nav aria-label="Message variants">
<button disabled={!branches.hasPrevious} onClick={branches.previous}>
Previous
</button>
<span>{branches.index + 1} / {branches.count}</span>
<button disabled={!branches.hasNext} onClick={branches.next}>
Next
</button>
</nav>
);
}

export function BranchedMessage({ message }: { message: ChatMessage }) {
return (
<Message.Root message={message}>
<Message.Content />
<BranchButtons />
</Message.Root>
);
}
```

## Inference mode

`useChat` exposes `inferenceMode` so your UI can show whether inference is running through cloud, server-local, or browser runtime.
Expand Down
15 changes: 8 additions & 7 deletions scripts/lint/ban-chat-antipatterns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,16 +96,17 @@ async function walk(
const FILE_SIZE_CEILINGS: Record<string, number> = {
// Chat preset implementation lives in chat/chat-preset.tsx. This barrel grew
// only for the explicit conversation-persistence contracts exported here.
"src/react/components/chat/chat/index.tsx": 280,
"src/react/components/chat/chat/index.tsx": 278,
// Message.Sources extracted to composition/message-sources.tsx.
"src/react/components/chat/chat/composition/message.tsx": 906,
"src/react/components/chat/chat/composition/message.tsx": 793,
// Includes the ChatSidebar.Item menu compound (Item.Menu/.Rename/.Delete).
// Split responsibilities before adding more behavior to this file.
"src/react/components/chat/chat/components/sidebar.tsx": 721,
// useComposerValue extracted to composition/use-composer-value.ts.
"src/react/components/chat/chat/composition/chat-composer.tsx": 619,
"src/react/components/chat/agent-picker.tsx": 494,
"src/react/components/chat/chat-actions.tsx": 315,
"src/react/components/chat/chat/components/sidebar.tsx": 628,
// Composer state and native action leaves live in focused sibling modules.
"src/react/components/chat/chat/composition/chat-composer.tsx": 389,
"src/react/components/chat/chat/composition/chat-input-actions.tsx": 159,
"src/react/components/chat/agent-picker.tsx": 429,
"src/react/components/chat/chat-actions.tsx": 203,
"src/react/components/chat/chat/controlled-chat.tsx": 242,
"src/react/components/chat/chat/app-mode-chat.tsx": 177,
};
Expand Down
166 changes: 166 additions & 0 deletions scripts/lint/check-test-typecheck-baseline.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { assert, assertEquals } from "#std/assert";
import {
type CheckJob,
type CheckResult,
evaluateRatchet,
runDenoCheck,
} from "./check-test-typecheck-baseline.ts";

function success(job: CheckJob): CheckResult {
return { ...job, success: true, output: "" };
}

function failure(job: CheckJob, output: string): CheckResult {
return { ...job, success: false, output };
}

function diagnostic(file: string, count = 1): string {
return Array.from(
{ length: count },
() => `TS2345 [ERROR]: synthetic\n at file:///repo/${file}:1:1`,
).join("\n\n");
}

Deno.test("test typecheck ratchet checks the clean happy path in one large job", async () => {
const files = Array.from(
{ length: 80 },
(_, index) => `src/clean-${index}.test.ts`,
);
const calls: CheckJob[] = [];
const outcome = await evaluateRatchet(files, new Set(), (job) => {
calls.push(job);
return Promise.resolve(success(job));
});

assertEquals(calls, [{ kind: "clean", files }]);
assertEquals(outcome.newRot, []);
assertEquals(outcome.fixed, []);
});

Deno.test("test typecheck ratchet recursively isolates a clean failure", async () => {
const files = Array.from(
{ length: 33 },
(_, index) => `src/clean-${index}.test.ts`,
);
const bad = files[21]!;
const calls: CheckJob[] = [];
const outcome = await evaluateRatchet(files, new Set(), (job) => {
calls.push(job);
return Promise.resolve(
job.files.includes(bad) ? failure(job, diagnostic(bad)) : success(job),
);
});

assertEquals(outcome.newRot, [bad]);
assertEquals(outcome.unattributedCleanResults, []);
assertEquals(calls[0], { kind: "clean", files });
assert(calls.some((job) => job.files.length === 1 && job.files[0] === bad));
});

Deno.test("test typecheck ratchet batches baseline files and splits capped diagnostics", async () => {
const files = Array.from(
{ length: 17 },
(_, index) => `src/baseline-${index}.test.ts`,
);
const baseline = new Set(files);
const bad = files[6]!;
const calls: CheckJob[] = [];
const outcome = await evaluateRatchet(files, baseline, (job) => {
calls.push(job);
if (!job.files.includes(bad)) return Promise.resolve(success(job));
return Promise.resolve(
failure(job, diagnostic(bad, job.files.length === 1 ? 1 : 100)),
);
});

assertEquals(calls.slice(0, 2).map((job) => job.files.length), [16, 1]);
assert(calls.some((job) => job.files.length === 1 && job.files[0] === bad));
assertEquals(outcome.failingBaselineCount, 1);
assertEquals(outcome.fixed, files.filter((file) => file !== bad).sort());
assertEquals(outcome.inconclusiveBaselineResults, []);
});

Deno.test("test typecheck ratchet fails closed on an unattributed clean exit", async () => {
const file = "src/unattributed.test.ts";
const outcome = await evaluateRatchet(
[file],
new Set(),
(job) => Promise.resolve(failure(job, "error: synthetic command failure")),
);

assertEquals(outcome.newRot, [file]);
assertEquals(outcome.unattributedCleanResults.length, 1);
});

Deno.test("test typecheck ratchet keeps an unattributed baseline exit inconclusive", async () => {
const file = "src/inconclusive.test.ts";
const outcome = await evaluateRatchet(
[file],
new Set([file]),
(job) => Promise.resolve(failure(job, "error: synthetic command failure")),
);

assertEquals(outcome.failingBaselineCount, 0);
assertEquals(outcome.fixed, []);
assertEquals(outcome.inconclusiveBaselineResults.length, 1);
});

Deno.test("test typecheck ratchet attributes a singleton dependency diagnostic to its entry point", async () => {
const file = "src/dependency-failure.test.ts";
const output = diagnostic("src/dependency.ts");
const baselineOutcome = await evaluateRatchet(
[file],
new Set([file]),
(job) => Promise.resolve(failure(job, output)),
);
const cleanOutcome = await evaluateRatchet(
[file],
new Set(),
(job) => Promise.resolve(failure(job, output)),
);

assertEquals(baselineOutcome.failingBaselineCount, 1);
assertEquals(baselineOutcome.inconclusiveBaselineResults, []);
assertEquals(cleanOutcome.newRot, [file]);
assertEquals(cleanOutcome.unattributedCleanResults, []);
});

Deno.test("test typecheck ratchet rechecks baseline entries hidden by another diagnostic", async () => {
const first = "src/first.test.ts";
const hidden = "src/hidden.test.ts";
const outcome = await evaluateRatchet(
[first, hidden],
new Set([first, hidden]),
(job) => {
if (job.files.includes(first)) {
return Promise.resolve(failure(job, diagnostic(first)));
}
return Promise.resolve(
failure(job, diagnostic("src/shared-dependency.ts")),
);
},
);

assertEquals(outcome.failingBaselineCount, 2);
assertEquals(outcome.fixed, []);
assertEquals(outcome.inconclusiveBaselineResults, []);
});

Deno.test("runDenoCheck kills a child that exceeds its deadline", async () => {
const startedAt = performance.now();
const result = await runDenoCheck(
{ kind: "clean", files: ["ignored.test.ts"] },
{
prefixArgs: [
"eval",
"await new Promise((resolve) => setTimeout(resolve, 60_000))",
],
timeoutMs: 25,
},
);

assertEquals(result.success, false);
assertEquals(result.timedOut, true);
assert(result.output.includes("deadline and was killed"));
assert(performance.now() - startedAt < 2_000);
});
Loading