feat(desktop): window project-table rows for the hosted page

A 500-item project view can exceed the bridge envelope, so the page asks
for one row window at a time instead of the shell holding the whole table
and paging it from memory.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-09-07 14:55:25 -04:00
parent 744db6d4a6
commit f7df329110
4 changed files with 176 additions and 0 deletions
+2
View File
@@ -4,6 +4,7 @@ import { MOBILE_WEB_SESSION_CAPABILITIES_METHOD } from './mobile-web-session-cap
import { MOBILE_WEB_SESSION_QUICK_COMMAND_METHODS } from './mobile-web-session-quick-commands'
import { MOBILE_WEB_SESSION_BROWSER_CREATE_METHOD } from './mobile-web-session-browser-create'
import { MOBILE_WEB_SOURCE_CONTROL_READ_METHODS } from './mobile-web-source-control-reads'
import { MOBILE_WEB_TASK_PROJECT_TABLE_METHOD } from './mobile-web-task-project-table'
import { MOBILE_WEB_SESSION_TERMINAL_CREATION_METHODS } from './mobile-web-session-terminal-creation'
import { MOBILE_WEB_NATIVE_CHAT_FILE_METHODS } from './mobile-web-native-chat-files'
import { MOBILE_WEB_TERMINAL_ACTION_METHODS } from './mobile-web-terminal-actions'
@@ -116,6 +117,7 @@ export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [
...UPDATER_METHODS,
...MOBILE_WEB_FILE_READ_METHODS,
...MOBILE_WEB_SOURCE_CONTROL_READ_METHODS,
MOBILE_WEB_TASK_PROJECT_TABLE_METHOD,
MOBILE_WEB_FILE_WATCH_METHOD,
...MOBILE_WEB_TERMINAL_ACTION_METHODS,
...MOBILE_WEB_NATIVE_CHAT_METHODS,
@@ -9,6 +9,7 @@ export const MOBILE_WEB_HOST_RPC_METHODS = new Set([
'mobileWeb.files.searchPaths',
'mobileWeb.files.read',
'mobileWeb.files.watch',
'mobileWeb.tasks.projectTable',
'mobileWeb.terminal.action',
'mobileWeb.nativeChat.read',
'mobileWeb.nativeChat.mutate',
@@ -0,0 +1,95 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { RpcContext } from '../core'
import { GITHUB_PROJECT_METHODS } from './github-project-methods'
import { MOBILE_WEB_TASK_PROJECT_TABLE_METHOD } from './mobile-web-task-project-table'
const context = { signal: new AbortController().signal } as RpcContext
const request = {
owner: 'octo',
ownerType: 'organization',
projectNumber: 4,
viewId: 'PVTV_1'
}
function fixture(raw: unknown) {
const source = GITHUB_PROJECT_METHODS.find(
(method) => method.name === 'github.project.viewTable'
)!
const handler = vi.spyOn(source, 'handler').mockResolvedValue(raw)
return {
handler,
run: (params: Record<string, unknown> = {}) =>
MOBILE_WEB_TASK_PROJECT_TABLE_METHOD.handler(
MOBILE_WEB_TASK_PROJECT_TABLE_METHOD.params!.parse({ ...request, ...params }),
context
) as Promise<Record<string, unknown>>
}
}
function table(rowCount: number, titleLength = 8) {
return {
data: {
project: { owner: 'octo', ownerType: 'organization', number: 4, host: 'github.com' },
selectedView: { id: 'PVTV_1', name: 'Board' },
totalCount: rowCount,
rows: Array.from({ length: rowCount }, (_, index) => ({
id: `row-${index}`,
content: { title: 'x'.repeat(titleLength), repository: 'octo/app', number: index }
}))
}
}
}
afterEach(() => vi.restoreAllMocks())
describe('mobileWeb.tasks.projectTable', () => {
it('returns the whole table in one window when it fits the bridge envelope', async () => {
const f = fixture(table(3))
const result = await f.run()
expect((result.data as { rows: unknown[] }).rows).toHaveLength(3)
expect(result.nextRowOffset).toBeUndefined()
})
it('clips an oversized table and reports where the next window starts', async () => {
const raw = table(500, 4_000)
expect(Buffer.byteLength(JSON.stringify(raw.data))).toBeGreaterThan(512 * 1024)
const f = fixture(raw)
const first = await f.run()
const rows = (first.data as { rows: { id: string }[] }).rows
expect(rows.length).toBeLessThan(500)
expect(rows[0]!.id).toBe('row-0')
expect(Buffer.byteLength(JSON.stringify(first.data))).toBeLessThanOrEqual(512 * 1024)
expect(first.nextRowOffset).toBe(rows.length)
const second = await f.run({ rowOffset: first.nextRowOffset })
expect((second.data as { rows: { id: string }[] }).rows[0]!.id).toBe(`row-${rows.length}`)
})
it('keeps the table metadata on every window so the caller can rebuild the table', async () => {
const f = fixture(table(500, 4_000))
const second = await f.run({ rowOffset: 400 })
expect(second.data).toMatchObject({ totalCount: 500, selectedView: { id: 'PVTV_1' } })
})
it('advances by one row even when a single row exceeds the budget', async () => {
const f = fixture(table(3, 600 * 1024))
const result = await f.run()
expect((result.data as { rows: unknown[] }).rows).toHaveLength(1)
expect(result.nextRowOffset).toBe(1)
})
it('passes a host failure envelope through untouched', async () => {
const failure = { ok: false, error: { message: 'View has 900 items.', type: 'too_large' } }
const f = fixture(failure)
expect(await f.run()).toEqual(failure)
})
it('never forwards the page row offset to the GitHub query', async () => {
const f = fixture(table(3))
await f.run({ rowOffset: 2 })
expect(f.handler).toHaveBeenCalledWith(
expect.not.objectContaining({ rowOffset: expect.anything() }),
context
)
})
})
@@ -0,0 +1,78 @@
import { z } from 'zod'
import { defineMethod, isStreamingMethod } from '../core'
import { GITHUB_PROJECT_METHODS } from './github-project-methods'
// The bridge envelope is 600 KB, and a 500-item project view can exceed it, so the page asks for
// one row window at a time and this handler decides where each window ends.
const MAX_RESULT_BYTES = 512 * 1024
const ProjectTableWindow = z.object({
owner: z.string().min(1).max(512),
host: z.string().max(512).optional(),
ownerType: z.enum(['organization', 'user']),
projectNumber: z.number().int().positive(),
viewId: z.string().min(1).max(240),
queryOverride: z.string().max(4_096).optional(),
rowOffset: z.number().int().nonnegative().max(100_000).optional()
})
type ProjectTableRow = Record<string, unknown>
function projectViewTableMethod() {
const method = GITHUB_PROJECT_METHODS.find((entry) => entry.name === 'github.project.viewTable')
if (!method || isStreamingMethod(method)) {
throw new Error('Missing unary method: github.project.viewTable')
}
return method
}
const viewTable = projectViewTableMethod()
export const MOBILE_WEB_TASK_PROJECT_TABLE_METHOD = defineMethod({
name: 'mobileWeb.tasks.projectTable',
params: ProjectTableWindow,
handler: async (params, context) => {
const { rowOffset = 0, ...request } = params
const raw = await viewTable.handler(request, context)
const table = tableOf(raw)
if (!table) {
return raw
}
const rows = Array.isArray(table.rows) ? (table.rows as ProjectTableRow[]) : []
const window = rowWindow(table, rows, rowOffset)
const nextRowOffset = rowOffset + window.length
return {
...(raw as Record<string, unknown>),
data: { ...table, rows: window },
...(nextRowOffset < rows.length ? { nextRowOffset } : {})
}
}
})
function tableOf(raw: unknown): Record<string, unknown> | null {
const envelope = raw as { ok?: boolean; data?: unknown } | null
if (!envelope || envelope.ok === false) {
return null
}
const table = envelope.data
return typeof table === 'object' && table !== null ? (table as Record<string, unknown>) : null
}
/** Always yields at least one row so a caller that has not reached the end always advances. */
function rowWindow(
table: Record<string, unknown>,
rows: ProjectTableRow[],
offset: number
): ProjectTableRow[] {
const window: ProjectTableRow[] = []
for (const row of rows.slice(offset)) {
window.push(row)
if (
window.length > 1 &&
Buffer.byteLength(JSON.stringify({ ...table, rows: window })) > MAX_RESULT_BYTES
) {
window.pop()
break
}
}
return window
}