mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 00:03:15 +00:00
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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user