Compare commits

...

2 Commits

Author SHA1 Message Date
Pat Sukprasert e3ac2d3d78 ci: run TypeScript lint through pre-commit
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
2026-07-29 22:30:04 +08:00
Pat Sukprasert 763786986e ci: enforce TypeScript lint checks
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
2026-07-29 21:41:13 +08:00
10 changed files with 48 additions and 43 deletions
+7 -11
View File
@@ -1,9 +1,10 @@
name: Lint
# Runs the project's pre-commit hooks (ruff, mypy, custom anti-pattern grep
# hooks, etc.) on every non-draft PR and on push to main. Surfaces as the
# `Pre-commit checks` check, a REQUIRED gate entry in merge-ready.yml. Draft PRs
# are skipped; `ready_for_review` refires so the check doesn't strand pending.
# Runs the project's pre-commit hooks (ruff, TypeScript lint, custom
# anti-pattern grep hooks, etc.) on every non-draft PR and on push to main.
# Surfaces as the `Pre-commit checks` check, a REQUIRED gate entry in
# merge-ready.yml. Draft PRs are skipped; `ready_for_review` refires so the
# check doesn't strand pending.
on:
pull_request:
@@ -83,7 +84,7 @@ jobs:
- name: Install web dependencies
# Pin the npm registry to the npmjs default; limit to the web package
# so the Electron package's large native devDependencies are not fetched.
# so Electron's large native devDependencies are not fetched.
env:
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
run: pnpm install --frozen-lockfile --filter web
@@ -120,14 +121,9 @@ jobs:
chmod +x /tmp/ktlint
sudo mv /tmp/ktlint /usr/local/bin/ktlint
- name: Run formatting, lint, and typing checks
- name: Run formatting and lint checks
run: uv run pre-commit run --all-files --show-diff-on-failure
# Type-checking is temporarily skipped in CI while the pnpm lockfile
# settles; `pnpm --filter web run type-check` still works locally.
# - name: Type-check web
# run: pnpm --filter web run type-check
# The four packages release in lockstep (identical versions + `==` sibling
# pins). Assert agreement on every change so drift from a bad merge or
# cherry-pick — however it happened — is caught before it reaches a release.
+7
View File
@@ -53,6 +53,13 @@ repos:
# fights the tooling).
exclude: ^(omnigent/server/static/web-ui/assets/|web/.*\.xcassets/|web/.*\.icon/)
- id: web-oxlint
name: web oxlint
language: system
entry: bash -c 'test -x web/node_modules/.bin/oxlint && cd web && node_modules/.bin/oxlint .'
files: ^(web/.*\.[cm]?[jt]sx?|web/\.oxlintrc\.json|web/package\.json|pnpm-lock\.yaml)$
pass_filenames: false
# Android Kotlin formatting + linting via ktlint (config:
# web/android/.editorconfig). The wrapper no-ops when ktlint is absent,
# so local machines without ktlint installed skip cleanly. CI installs
+5
View File
@@ -92,6 +92,11 @@ lint: _ensure-uv
lint-all: _ensure-uv
uv run pre-commit run --all-files
[group('lint')]
lint-ts:
pnpm install --frozen-lockfile --filter web
pnpm --filter web run lint
# --- Lockfile maintenance ---
[group('lint')]
@@ -253,6 +253,7 @@ describe("isUnexpectedTerminalClose", () => {
class FakeWebSocket {
static OPEN = 1;
static CLOSED = 3;
static instances: FakeWebSocket[] = [];
readyState = 0;
binaryType = "blob";
sent: Array<string | Uint8Array> = [];
@@ -262,6 +263,7 @@ class FakeWebSocket {
constructor(url: string) {
this.url = url;
FakeWebSocket.instances.push(this);
}
addEventListener(type: string, fn: (ev: unknown) => void) {
@@ -305,20 +307,10 @@ class FakeResizeObserver {
}
describe("TerminalSession", () => {
let lastSocket: FakeWebSocket | null = null;
beforeEach(() => {
lastSocket = null;
FakeWebSocket.instances = [];
FakeResizeObserver.instances = [];
vi.stubGlobal(
"WebSocket",
class extends FakeWebSocket {
constructor(url: string) {
super(url);
lastSocket = this;
}
},
);
vi.stubGlobal("WebSocket", FakeWebSocket);
vi.stubGlobal("ResizeObserver", FakeResizeObserver);
});
@@ -339,7 +331,7 @@ describe("TerminalSession", () => {
onActivity,
onInput,
);
return { session, states, container, socket: lastSocket as unknown as FakeWebSocket };
return { session, states, container, socket: FakeWebSocket.instances.at(-1)! };
}
it("reports 'connected' and sends an initial resize on socket open", () => {
@@ -81,12 +81,14 @@ export function ScheduleFields({
const hourColumnRef = useRef<HTMLDivElement | null>(null);
const minuteColumnRef = useRef<HTMLDivElement | null>(null);
const periodColumnRef = useRef<HTMLDivElement | null>(null);
const [timeText, setTimeText] = useState(() => formatInputValue(model, isHourly));
const [timeText, setTimeText] = useState(() =>
formatInputValue(model.hour, model.minute, isHourly),
);
const [timePickerOpen, setTimePickerOpen] = useState(false);
useEffect(() => {
if (document.activeElement === inputRef.current) return;
setTimeText(formatInputValue(model, isHourly));
setTimeText(formatInputValue(model.hour, model.minute, isHourly));
}, [isHourly, model.hour, model.minute]);
const pickerParts = toPickerParts(getPickerTime());
@@ -363,10 +365,10 @@ function pad(n: number): string {
return n.toString().padStart(2, "0");
}
function formatInputValue(model: ScheduleModel, isHourly: boolean): string {
if (isHourly) return formatMinuteInput(model.minute);
if (!Number.isInteger(model.hour) || !Number.isInteger(model.minute)) return "";
return formatTimeInput(model.hour, model.minute);
function formatInputValue(hour: number, minute: number, isHourly: boolean): string {
if (isHourly) return formatMinuteInput(minute);
if (!Number.isInteger(hour) || !Number.isInteger(minute)) return "";
return formatTimeInput(hour, minute);
}
function formatMinuteInput(minute: number): string {
+4 -4
View File
@@ -68,10 +68,10 @@ export function useRecentWorkspaces(hostId: string | null): RecentWorkspaces {
// with the current host on the same render. A prior effect-based hydration
// lagged one render behind hostId, which let a consumer briefly observe the
// previous host's paths right after a host switch (a cross-host leak).
const recent = useMemo(
() => (hostId === null ? [] : (readAll()[hostId] ?? [])),
[hostId, revision],
);
const recent = useMemo(() => {
void revision;
return hostId === null ? [] : (readAll()[hostId] ?? []);
}, [hostId, revision]);
const addRecent = useCallback(
(path: string) => {
+4 -4
View File
@@ -49,7 +49,7 @@ async function extractConfigYaml(file: File): Promise<string> {
const tar = new Uint8Array(buf);
// First tar entry: 512-byte header, then content.
// File size is at offset 124, 12 bytes, octal null-terminated.
const sizeStr = new TextDecoder().decode(tar.slice(124, 135)).replace(/\0/g, "");
const sizeStr = new TextDecoder().decode(tar.slice(124, 135)).replaceAll("\0", "");
const size = parseInt(sizeStr, 8);
return new TextDecoder().decode(tar.slice(512, 512 + size));
}
@@ -58,18 +58,18 @@ async function extractConfigYaml(file: File): Promise<string> {
async function extractAgentsMd(file: File): Promise<string | null> {
const buf = await file.arrayBuffer();
const tar = new Uint8Array(buf);
const size0Str = new TextDecoder().decode(tar.slice(124, 135)).replace(/\0/g, "");
const size0Str = new TextDecoder().decode(tar.slice(124, 135)).replaceAll("\0", "");
const size0 = parseInt(size0Str, 8);
const blocks0 = Math.ceil(size0 / 512);
const entry1Start = 512 + blocks0 * 512;
if (entry1Start + 512 > tar.length) return null;
const name1 = new TextDecoder()
.decode(tar.slice(entry1Start, entry1Start + 100))
.replace(/\0/g, "");
.replaceAll("\0", "");
if (!name1.startsWith("AGENTS.md")) return null;
const size1Str = new TextDecoder()
.decode(tar.slice(entry1Start + 124, entry1Start + 135))
.replace(/\0/g, "");
.replaceAll("\0", "");
const size1 = parseInt(size1Str, 8);
return new TextDecoder().decode(tar.slice(entry1Start + 512, entry1Start + 512 + size1));
}
+1
View File
@@ -104,6 +104,7 @@ export async function fetchLastAssistantText(
): Promise<string | undefined> {
try {
const params = new URLSearchParams({ limit: String(SCAN_ITEMS), order: "desc" });
// oxlint-disable-next-line eslint/no-restricted-globals -- This lookup uses the page origin.
const res = await fetch(`/v1/sessions/${encodeURIComponent(sessionId)}/items?${params}`);
if (!res.ok) return undefined;
const json = (await res.json()) as { data?: unknown };
+6 -5
View File
@@ -99,26 +99,27 @@ export function CommentsPanel({
const currentAuthorId = getCurrentAuthorId();
const canModify = (c: Comment): boolean =>
canEdit && (c.created_by == null || c.created_by === currentAuthorId);
const activeSelectionStart = activeSelection?.start_index;
const activeSelectionEnd = activeSelection?.end_index;
useEffect(() => {
setBody("");
if (pendingBodyRef) pendingBodyRef.current = "";
}, [activeSelection?.start_index, activeSelection?.end_index]);
}, [activeSelectionStart, activeSelectionEnd, pendingBodyRef]);
// Auto-focus the textarea when a new pending selection appears (no existing
// comment at that range) so the user can start typing immediately.
useEffect(() => {
if (!activeSelection) return;
if (activeSelectionStart == null || activeSelectionEnd == null) return;
const isExisting = comments.some(
(c) =>
c.start_index === activeSelection.start_index && c.end_index === activeSelection.end_index,
(c) => c.start_index === activeSelectionStart && c.end_index === activeSelectionEnd,
);
if (!isExisting) {
// rAF ensures the textarea has been rendered before we try to focus it.
const id = requestAnimationFrame(() => addCommentTextareaRef.current?.focus());
return () => cancelAnimationFrame(id);
}
}, [activeSelection?.start_index, activeSelection?.end_index, comments]);
}, [activeSelectionStart, activeSelectionEnd, comments]);
// Selecting a highlighted range in the file activates its comment; if that
// comment lives on the other tab, switch to the tab that holds it so its card
+1
View File
@@ -39,6 +39,7 @@ import "./pdfViewer.css";
// Point pdf.js at its worker. Vite imports the worker entry as a static asset so
// the bundled SPA emits it as a hashed file without relying on the package
// path resolving from `node_modules`.
// oxlint-disable-next-line import/default -- Vite's `?url` import returns the asset URL.
import pdfWorkerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url";
pdfjs.GlobalWorkerOptions.workerSrc = pdfWorkerUrl;