diff --git a/mobile/host-web-app/h/[hostId]/tasks.tsx b/mobile/host-web-app/h/[hostId]/tasks.tsx index 58974df04c2..cbc265a013d 100644 --- a/mobile/host-web-app/h/[hostId]/tasks.tsx +++ b/mobile/host-web-app/h/[hostId]/tasks.tsx @@ -1,19 +1,20 @@ import { useMemo } from 'react' import MobileTasksScreen from '../../../app/h/[hostId]/tasks' import { useMobileWebNativeShell } from '../../../../src/mobile-web/src/native-shell-channel' -import { webHostTaskDetailOperations } from '../../../src/tasks/web-host-task-detail-operations' +import { nativeHostTaskDetailOperations } from '../../../src/tasks/native-host-task-detail-operations' +import { nativeHostTaskItemFileOperations } from '../../../src/tasks/native-host-task-item-file-operations' +import { nativeHostTaskItemMutationOperations } from '../../../src/tasks/native-host-task-item-mutation-operations' +import { nativeHostTaskItemReviewOperations } from '../../../src/tasks/native-host-task-item-review-operations' +import { nativeHostTaskLinearOperations } from '../../../src/tasks/native-host-task-linear-operations' +import { nativeHostTaskListOperations } from '../../../src/tasks/native-host-task-list-operations' +import { nativeHostTaskPreferenceOperations } from '../../../src/tasks/native-host-task-preference-operations' +import { nativeHostTaskProjectFileOperations } from '../../../src/tasks/native-host-task-project-file-operations' +import { nativeHostTaskProjectMutationOperations } from '../../../src/tasks/native-host-task-project-mutation-operations' +import { nativeHostTaskProviderWriteOperations } from '../../../src/tasks/native-host-task-provider-write-operations' +import { nativeHostTaskReadOperations } from '../../../src/tasks/native-host-task-read-operations' import { webHostTaskDeviceOperations } from '../../../src/tasks/web-host-task-device-operations' -import { webHostTaskItemFileOperations } from '../../../src/tasks/web-host-task-item-file-operations' -import { webHostTaskItemMutationOperations } from '../../../src/tasks/web-host-task-item-mutation-operations' -import { webHostTaskItemReviewOperations } from '../../../src/tasks/web-host-task-item-review-operations' -import { webHostTaskLinearOperations } from '../../../src/tasks/web-host-task-linear-operations' -import { webHostTaskListOperations } from '../../../src/tasks/web-host-task-list-operations' -import { webHostTaskPreferenceOperations } from '../../../src/tasks/web-host-task-preference-operations' -import { webHostTaskProjectFileOperations } from '../../../src/tasks/web-host-task-project-file-operations' -import { webHostTaskProjectMutationOperations } from '../../../src/tasks/web-host-task-project-mutation-operations' import { webHostTaskProjectReadOperations } from '../../../src/tasks/web-host-task-project-read-operations' -import { webHostTaskProviderWriteOperations } from '../../../src/tasks/web-host-task-provider-write-operations' -import { webHostTaskReadOperations } from '../../../src/tasks/web-host-task-read-operations' +import { webHostTaskRpcSender } from '../../../src/tasks/web-host-task-rpc-sender' import { webHostWorkspaceCreationOperations } from '../../../src/worktree/web-host-workspace-creation-operations' const HOSTED_PAGE_HOST_ID = 'paired-orca-desktop' @@ -24,20 +25,21 @@ export default function HostMobileWebTasksRoute() { if (!shell.client) { return null } + const sender = webHostTaskRpcSender(shell.client.host) return { - detail: webHostTaskDetailOperations(shell.client), + detail: nativeHostTaskDetailOperations(sender), device: webHostTaskDeviceOperations(shell.client), - itemFile: webHostTaskItemFileOperations(shell.client), - itemMutation: webHostTaskItemMutationOperations(shell.client), - itemReview: webHostTaskItemReviewOperations(shell.client), - linear: webHostTaskLinearOperations(shell.client), - list: webHostTaskListOperations(shell.client), - preference: webHostTaskPreferenceOperations(shell.client), - projectFile: webHostTaskProjectFileOperations(shell.client), - projectMutation: webHostTaskProjectMutationOperations(shell.client), - projectRead: webHostTaskProjectReadOperations(shell.client), - providerWrite: webHostTaskProviderWriteOperations(shell.client), - read: webHostTaskReadOperations(shell.client), + itemFile: nativeHostTaskItemFileOperations(sender), + itemMutation: nativeHostTaskItemMutationOperations(sender), + itemReview: nativeHostTaskItemReviewOperations(sender), + linear: nativeHostTaskLinearOperations(sender), + list: nativeHostTaskListOperations(sender), + preference: nativeHostTaskPreferenceOperations(sender), + projectFile: nativeHostTaskProjectFileOperations(sender), + projectMutation: nativeHostTaskProjectMutationOperations(sender), + projectRead: webHostTaskProjectReadOperations(sender), + providerWrite: nativeHostTaskProviderWriteOperations(sender), + read: nativeHostTaskReadOperations(sender), workspaceCreation: webHostWorkspaceCreationOperations(shell.client) } }, [shell.client]) diff --git a/mobile/src/tasks/mobile-web-tasks-screen-binding-source.test.ts b/mobile/src/tasks/mobile-web-tasks-screen-binding-source.test.ts index 24b717db759..51bf4152cbc 100644 --- a/mobile/src/tasks/mobile-web-tasks-screen-binding-source.test.ts +++ b/mobile/src/tasks/mobile-web-tasks-screen-binding-source.test.ts @@ -16,17 +16,7 @@ describe('mobile web Tasks screen binding', () => { "import MobileTasksScreen from '../../../app/h/[hostId]/tasks'" ) expect(hostedTasksRoute).toContain('(request: Promise): Promise { +export async function projectResult(request: Promise): Promise { const response = (await request) as { ok: boolean result?: { ok?: boolean; error?: { message?: string } } diff --git a/mobile/src/tasks/web-host-task-detail-operations.ts b/mobile/src/tasks/web-host-task-detail-operations.ts deleted file mode 100644 index b0a41b70364..00000000000 --- a/mobile/src/tasks/web-host-task-detail-operations.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { MobileWebBridgeClient } from '../../../src/mobile-web/src/mobile-web-bridge-client' -import type { HostTaskDetailOperations } from './host-task-detail-operations' - -export function webHostTaskDetailOperations( - client: MobileWebBridgeClient -): HostTaskDetailOperations { - return { - async listGitHubLabels(repoId) { - return (await client.task.listGitHubLabels({ repoId })).labels - }, - async listGitHubAssignableUsers(repoId) { - return (await client.task.listGitHubAssignableUsers({ repoId })).users - }, - loadGitHub: (payload) => client.task.loadGitHubDetail(payload), - loadGitLab(payload) { - if (!payload.targetId) { - throw new Error('Task target is unavailable') - } - return client.task.loadGitLabDetail({ targetId: payload.targetId }) - }, - loadLinear(payload) { - if (!payload.targetId) { - throw new Error('Linear task target is unavailable') - } - return client.task.loadLinearDetail({ targetId: payload.targetId }) - } - } -} diff --git a/mobile/src/tasks/web-host-task-item-file-operations.ts b/mobile/src/tasks/web-host-task-item-file-operations.ts deleted file mode 100644 index d3899614388..00000000000 --- a/mobile/src/tasks/web-host-task-item-file-operations.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { MobileWebBridgeClient } from '../../../src/mobile-web/src/mobile-web-bridge-client' -import type { HostTaskGitHubItemTarget } from './host-task-item-mutation-operations' -import type { HostTaskItemFileOperations } from './host-task-item-file-operations' - -export function webHostTaskItemFileOperations( - client: MobileWebBridgeClient -): HostTaskItemFileOperations { - return { - async refreshChecks(target) { - return (await client.task.refreshHostedTaskChecks({ targetId: targetId(target) })).checks - }, - async rerunChecks(target, _headSha, failedOnly) { - await client.task.rerunHostedTaskChecks({ targetId: targetId(target), failedOnly }) - }, - async setFileViewed(target, payload) { - await client.task.setHostedTaskFileViewed({ - targetId: targetId(target), - path: payload.path, - viewed: payload.viewed - }) - }, - loadFileContents: (target, payload) => - client.task.loadHostedTaskFileContents({ - targetId: targetId(target), - path: payload.path - }), - async addInlineComment(target, payload) { - return ( - await client.task.addHostedTaskInlineComment({ - targetId: targetId(target), - path: payload.path, - line: payload.line, - body: payload.body - }) - ).comment - } - } -} - -function targetId(target: HostTaskGitHubItemTarget): string { - if (!target.targetId) { - throw new Error('Task file authority is unavailable') - } - return target.targetId -} diff --git a/mobile/src/tasks/web-host-task-item-mutation-operations.ts b/mobile/src/tasks/web-host-task-item-mutation-operations.ts deleted file mode 100644 index d974921e06b..00000000000 --- a/mobile/src/tasks/web-host-task-item-mutation-operations.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { MobileWebBridgeClient } from '../../../src/mobile-web/src/mobile-web-bridge-client' -import type { - HostTaskItemMutationOperations, - HostTaskItemMutationTarget -} from './host-task-item-mutation-operations' - -export function webHostTaskItemMutationOperations( - client: MobileWebBridgeClient -): HostTaskItemMutationOperations { - return { - async setClosed(target, closed) { - await client.task.updateHostedTaskStatus({ targetId: targetId(target), closed }) - }, - async updateMetadata(target, updates) { - await client.task.updateHostedTaskMetadata({ targetId: targetId(target), updates }) - } - } -} - -function targetId(target: HostTaskItemMutationTarget): string { - if (!target.targetId) { - throw new Error('Task mutation authority is unavailable') - } - return target.targetId -} diff --git a/mobile/src/tasks/web-host-task-item-review-operations.ts b/mobile/src/tasks/web-host-task-item-review-operations.ts deleted file mode 100644 index cee93b65e4e..00000000000 --- a/mobile/src/tasks/web-host-task-item-review-operations.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { MobileWebBridgeClient } from '../../../src/mobile-web/src/mobile-web-bridge-client' -import type { HostTaskItemMutationTarget } from './host-task-item-mutation-operations' -import type { HostTaskItemReviewOperations } from './host-task-item-review-operations' - -export function webHostTaskItemReviewOperations( - client: MobileWebBridgeClient -): HostTaskItemReviewOperations { - return { - async addComment(target, body) { - return (await client.task.addHostedTaskComment({ targetId: targetId(target), body })).comment - }, - async requestReviewers(target, reviewers) { - await client.task.requestHostedTaskReviewers({ - targetId: targetId(target), - reviewers - }) - }, - async resolveThread(target, threadId, resolve) { - await client.task.resolveHostedTaskReviewThread({ - targetId: targetId(target), - threadId, - resolve - }) - }, - async replyReviewComment(target, payload) { - // The hosted bridge publishes no comment on reply; callers fall back to a local entry. - await client.task.replyHostedTaskReviewComment({ - targetId: targetId(target), - ...payload - }) - return undefined - }, - async merge(target, method) { - await client.task.mergeHostedTaskReview({ targetId: targetId(target), method }) - } - } -} - -function targetId(target: HostTaskItemMutationTarget): string { - if (!target.targetId) { - throw new Error('Task review authority is unavailable') - } - return target.targetId -} diff --git a/mobile/src/tasks/web-host-task-linear-operations.ts b/mobile/src/tasks/web-host-task-linear-operations.ts deleted file mode 100644 index 80f793674dc..00000000000 --- a/mobile/src/tasks/web-host-task-linear-operations.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { MobileWebBridgeClient } from '../../../src/mobile-web/src/mobile-web-bridge-client' -import type { HostTaskLinearOperations, HostTaskLinearTarget } from './host-task-linear-operations' - -export function webHostTaskLinearOperations( - client: MobileWebBridgeClient -): HostTaskLinearOperations { - return { - async connect(apiKey) { - await client.task.connectLinear({ apiKey }) - }, - async listTeams() { - return (await client.task.listLinearTeams({})).teams - }, - async teamStates(target) { - return (await client.task.listLinearTeamStates({ targetId: targetId(target) })).states - }, - async selectWorkspace(workspaceId) { - await client.task.selectLinearWorkspace({ workspaceId }) - }, - async updateState(target, stateId) { - await client.task.updateLinearIssueState({ targetId: targetId(target), stateId }) - }, - async addComment(target, body) { - return (await client.task.addLinearIssueComment({ targetId: targetId(target), body })).id - }, - async loadIssue(target) { - return (await client.task.loadLinearIssue({ targetId: targetId(target) })).issue - }, - async createSubIssue(target, title) { - return (await client.task.createLinearSubIssue({ targetId: targetId(target), title })).issue - }, - async createIssue(payload) { - return ( - await client.task.createLinearIssue({ - teamId: payload.team.id, - ...(payload.team.workspaceId ? { workspaceId: payload.team.workspaceId } : {}), - title: payload.title, - ...(payload.description ? { description: payload.description } : {}) - }) - ).issue - } - } -} - -function targetId(target: HostTaskLinearTarget): string { - if (!target.targetId) { - throw new Error('Linear task authority is unavailable') - } - return target.targetId -} diff --git a/mobile/src/tasks/web-host-task-list-operations.ts b/mobile/src/tasks/web-host-task-list-operations.ts deleted file mode 100644 index 0b32f74f390..00000000000 --- a/mobile/src/tasks/web-host-task-list-operations.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { MobileWebBridgeClient } from '../../../src/mobile-web/src/mobile-web-bridge-client' -import type { HostTaskListOperations } from './host-task-list-operations' - -export function webHostTaskListOperations(client: MobileWebBridgeClient): HostTaskListOperations { - return { - listGitHub: (payload) => client.task.listGitHub(payload), - async countGitHub(payload) { - return (await client.task.countGitHub(payload)).count - }, - listGitLab: (payload) => client.task.listGitLab(payload), - async listGitLabTodos(repoId) { - return (await client.task.listGitLabTodos({ repoId })).items - }, - async listLinear(payload) { - return (await client.task.listLinear(payload)).items - } - } -} diff --git a/mobile/src/tasks/web-host-task-operations.test.ts b/mobile/src/tasks/web-host-task-operations.test.ts new file mode 100644 index 00000000000..5c27f6c259f --- /dev/null +++ b/mobile/src/tasks/web-host-task-operations.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it, vi } from 'vitest' +import type { MobileWebHostRequestClient } from '../../../src/mobile-web/src/mobile-web-host-request-client' +import { nativeHostTaskItemMutationOperations } from './native-host-task-item-mutation-operations' +import { nativeHostTaskListOperations } from './native-host-task-list-operations' +import { nativeHostTaskPreferenceOperations } from './native-host-task-preference-operations' +import { nativeHostTaskReadOperations } from './native-host-task-read-operations' +import { webHostTaskProjectReadOperations } from './web-host-task-project-read-operations' +import { webHostTaskRpcSender } from './web-host-task-rpc-sender' + +type HostRequest = { method: string; params: Record } + +function hostFixture(reply: (request: HostRequest) => unknown) { + const requests: HostRequest[] = [] + const request = vi.fn(async (payload: HostRequest) => { + requests.push(payload) + return reply(payload) + }) + const host = { request } as unknown as MobileWebHostRequestClient + return { requests, sender: webHostTaskRpcSender(host) } +} + +function projectRow(index: number) { + return { + id: `row-${index}`, + itemType: 'ISSUE' as const, + position: index, + updatedAt: '2026-09-07T00:00:00Z', + fieldValuesByFieldId: {}, + content: { + number: index + 1, + title: `Item ${index}`, + body: null, + url: 'https://github.com/octo/app/issues/1', + state: 'OPEN', + isDraft: false, + repository: 'octo/app', + labels: [], + assignees: [] + } + } +} + +function projectTable(rows: ReturnType[], totalCount: number) { + return { + project: { + owner: 'octo', + ownerType: 'organization', + number: 4, + host: 'github.com', + id: 'PVT_1', + title: 'Roadmap', + url: 'https://github.com/orgs/octo/projects/4' + }, + selectedView: { + id: 'PVTV_1', + number: 1, + name: 'Board', + filter: '', + layout: 'BOARD_LAYOUT', + fields: [], + groupByFields: [], + sortByFields: [] + }, + totalCount, + rows + } +} + +const projectRequest = { + owner: 'octo', + ownerType: 'organization' as const, + number: 4, + viewId: 'PVTV_1' +} + +describe('hosted task operations over the generic host lane', () => { + it('names the desktop method the native app calls and sends no workspace handle', async () => { + const f = hostFixture(() => ({ repos: [{ id: 'repo-1', displayName: 'app' }] })) + await nativeHostTaskReadOperations(f.sender).listRepositories() + expect(f.requests).toEqual([{ method: 'repo.list', params: {} }]) + }) + + it('addresses a work item by its host repository and number, not an opaque handle', async () => { + const f = hostFixture(() => ({ ok: true })) + await nativeHostTaskItemMutationOperations(f.sender).setClosed( + { provider: 'github', repoId: 'repo-1', number: 12, type: 'issue' }, + true + ) + expect(f.requests[0]!.method).toBe('github.updateIssue') + expect(f.requests[0]!.params).toMatchObject({ repo: 'id:repo-1', number: 12 }) + }) + + it('forwards a list request unchanged and returns the host items', async () => { + const f = hostFixture(() => ({ items: [{ number: 7, type: 'issue', title: 'Bug' }] })) + const result = await nativeHostTaskListOperations(f.sender).listGitHub({ + repoId: 'repo-1', + limit: 25, + filter: 'all' + } as Parameters['listGitHub']>[0]) + expect(f.requests[0]!.method).toBe('github.listWorkItems') + expect(result.items).toHaveLength(1) + }) + + it('writes a task preference straight to the desktop settings method', async () => { + const f = hostFixture(() => ({})) + await nativeHostTaskPreferenceOperations(f.sender).updateSettings({ + defaultTaskSource: 'github' + }) + expect(f.requests).toEqual([ + { method: 'settings.update', params: { defaultTaskSource: 'github' } } + ]) + }) + + it('rejects when the desktop refuses the request', async () => { + const host = { + request: vi.fn(async () => { + throw new Error('forbidden') + }) + } as unknown as MobileWebHostRequestClient + await expect( + nativeHostTaskReadOperations(webHostTaskRpcSender(host)).listRepositories() + ).rejects.toThrow('forbidden') + }) + + it('reassembles a project table from every row window the desktop returns', async () => { + const rows = [projectRow(0), projectRow(1), projectRow(2)] + const f = hostFixture((request) => { + const offset = (request.params.rowOffset as number) ?? 0 + const window = rows.slice(offset, offset + 2) + return { + data: projectTable(window, rows.length), + ...(offset + window.length < rows.length ? { nextRowOffset: offset + window.length } : {}) + } + }) + const table = await webHostTaskProjectReadOperations(f.sender).loadTable(projectRequest) + expect(table.rows.map((row) => row.id)).toEqual(['row-0', 'row-1', 'row-2']) + expect(f.requests.map((request) => request.params.rowOffset)).toEqual([0, 2]) + expect(f.requests.every((request) => request.method === 'mobileWeb.tasks.projectTable')).toBe( + true + ) + }) + + it('stops paging a project table when a window adds no rows', async () => { + const f = hostFixture(() => ({ data: projectTable([], 0), nextRowOffset: 0 })) + const table = await webHostTaskProjectReadOperations(f.sender).loadTable(projectRequest) + expect(table.rows).toEqual([]) + expect(f.requests).toHaveLength(1) + }) + + it('rejects a project table the page schema does not accept', async () => { + const f = hostFixture(() => ({ data: { rows: [{ id: 7 }] } })) + await expect( + webHostTaskProjectReadOperations(f.sender).loadTable(projectRequest) + ).rejects.toThrow() + }) +}) diff --git a/mobile/src/tasks/web-host-task-preference-operations.test.ts b/mobile/src/tasks/web-host-task-preference-operations.test.ts deleted file mode 100644 index 57970a51474..00000000000 --- a/mobile/src/tasks/web-host-task-preference-operations.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { MobileWebBridgeClient } from '../../../src/mobile-web/src/mobile-web-bridge-client' -import { webHostTaskPreferenceOperations } from './web-host-task-preference-operations' - -describe('web host task preference operations', () => { - it('uses strict task updates and the existing opaque trust operation', async () => { - const updateResume = vi.fn().mockResolvedValue(null) - const updateSettings = vi.fn().mockResolvedValue(null) - const persistTrust = vi.fn().mockResolvedValue({ - 'repo-page-1': { all: { approvedAt: 10 } } - }) - const operations = webHostTaskPreferenceOperations({ - task: { updateResume, updateSettings }, - workspaceCreation: { persistTrust } - } as unknown as MobileWebBridgeClient) - - await operations.updateResume({ githubMode: 'project' }) - await operations.updateSettings({ defaultTaskSource: 'linear' }) - await expect( - operations.persistSetupTrust({ - trust: {}, - repoId: 'repo-page-1', - contentHash: 'f'.repeat(64), - alwaysTrust: true - }) - ).resolves.toEqual({ - 'repo-page-1': { all: { approvedAt: 10 } } - }) - - expect(updateResume).toHaveBeenCalledWith({ - taskResumeState: { githubMode: 'project' } - }) - expect(updateSettings).toHaveBeenCalledWith({ defaultTaskSource: 'linear' }) - expect(persistTrust).toHaveBeenCalledWith({ - trust: {}, - repoId: 'repo-page-1', - contentHash: 'f'.repeat(64), - alwaysTrust: true - }) - }) -}) diff --git a/mobile/src/tasks/web-host-task-preference-operations.ts b/mobile/src/tasks/web-host-task-preference-operations.ts deleted file mode 100644 index eec5a20cd47..00000000000 --- a/mobile/src/tasks/web-host-task-preference-operations.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { MobileWebBridgeClient } from '../../../src/mobile-web/src/mobile-web-bridge-client' -import type { HostTaskPreferenceOperations } from './host-task-preference-operations' - -export function webHostTaskPreferenceOperations( - client: MobileWebBridgeClient -): HostTaskPreferenceOperations { - return { - async updateResume(taskResumeState) { - await client.task.updateResume({ taskResumeState }) - }, - async updateSettings(settings) { - await client.task.updateSettings(settings) - }, - persistSetupTrust: (args) => client.workspaceCreation.persistTrust(args) - } -} diff --git a/mobile/src/tasks/web-host-task-project-file-operations.ts b/mobile/src/tasks/web-host-task-project-file-operations.ts deleted file mode 100644 index 0cfc11ba91a..00000000000 --- a/mobile/src/tasks/web-host-task-project-file-operations.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { MobileWebBridgeClient } from '../../../src/mobile-web/src/mobile-web-bridge-client' -import type { HostTaskProjectFileOperations } from './host-task-project-file-operations' -import type { HostTaskProjectItemTarget } from './host-task-project-mutation-operations' - -export function webHostTaskProjectFileOperations( - client: MobileWebBridgeClient -): HostTaskProjectFileOperations { - return { - async refreshChecks(target, repoId, headSha) { - return ( - await client.task.refreshProjectChecks({ - targetId: targetId(target), - repoId, - ...(headSha ? { headSha } : {}) - }) - ).checks - }, - async setFileViewed(target, repoId, payload) { - await client.task.setProjectFileViewed({ targetId: targetId(target), repoId, ...payload }) - }, - loadFileContents: (target, repoId, payload) => - client.task.loadProjectFileContents({ targetId: targetId(target), repoId, ...payload }), - async addInlineComment(target, repoId, payload) { - return ( - await client.task.addProjectInlineComment({ - targetId: targetId(target), - repoId, - ...payload - }) - ).comment - } - } -} - -function targetId(target: HostTaskProjectItemTarget): string { - if (!target.targetId) { - throw new Error('Project mutation authority is unavailable') - } - return target.targetId -} diff --git a/mobile/src/tasks/web-host-task-project-mutation-operations.ts b/mobile/src/tasks/web-host-task-project-mutation-operations.ts deleted file mode 100644 index eee6a189359..00000000000 --- a/mobile/src/tasks/web-host-task-project-mutation-operations.ts +++ /dev/null @@ -1,93 +0,0 @@ -import type { MobileWebBridgeClient } from '../../../src/mobile-web/src/mobile-web-bridge-client' -import type { - HostTaskProjectItemTarget, - HostTaskProjectMutationOperations -} from './host-task-project-mutation-operations' - -export function webHostTaskProjectMutationOperations( - client: MobileWebBridgeClient -): HostTaskProjectMutationOperations { - return { - async updateItem(target, updates) { - await client.task.updateProjectItem({ targetId: targetId(target), updates }) - }, - async addComment(target, body) { - return (await client.task.addProjectComment({ targetId: targetId(target), body })).comment - }, - async updateComment(target, commentId, body) { - await client.task.updateProjectComment({ - targetId: targetId(target), - commentId, - body - }) - }, - async deleteComment(target, commentId) { - await client.task.deleteProjectComment({ targetId: targetId(target), commentId }) - }, - async updateMetadata(target, updates) { - await client.task.updateProjectMetadata({ targetId: targetId(target), updates }) - }, - async updateField(target, fieldId, value) { - await client.task.updateProjectField({ targetId: targetId(target), fieldId, value }) - }, - async updateIssueType(target, issueTypeId) { - await client.task.updateProjectIssueType({ - targetId: targetId(target), - issueTypeId - }) - }, - async resolveReviewThread(target, repoId, threadId, resolve) { - await client.task.resolveProjectReviewThread({ - targetId: targetId(target), - repoId, - threadId, - resolve - }) - }, - // The hosted bridge publishes no comment on these; callers fall back to a local entry. - async replyReviewComment(target, repoId, payload) { - await client.task.replyProjectReviewComment({ - targetId: targetId(target), - repoId, - ...payload - }) - return undefined - }, - async addConversationComment(target, repoId, body) { - await client.task.addProjectConversationComment({ - targetId: targetId(target), - repoId, - body - }) - return undefined - }, - async requestReviewers(target, repoId, reviewers) { - await client.task.requestProjectReviewers({ - targetId: targetId(target), - repoId, - reviewers - }) - }, - async rerunChecks(target, repoId, payload) { - await client.task.rerunProjectChecks({ - targetId: targetId(target), - repoId, - ...payload - }) - }, - async merge(target, repoId, method) { - await client.task.mergeProjectPullRequest({ - targetId: targetId(target), - repoId, - method - }) - } - } -} - -function targetId(target: HostTaskProjectItemTarget): string { - if (!target.targetId) { - throw new Error('Project mutation authority is unavailable') - } - return target.targetId -} diff --git a/mobile/src/tasks/web-host-task-project-read-operations.ts b/mobile/src/tasks/web-host-task-project-read-operations.ts index 7da3329fdee..8f7cb627d62 100644 --- a/mobile/src/tasks/web-host-task-project-read-operations.ts +++ b/mobile/src/tasks/web-host-task-project-read-operations.ts @@ -1,45 +1,58 @@ -import type { MobileWebBridgeClient } from '../../../src/mobile-web/src/mobile-web-bridge-client' -import { MobileWebTaskProjectTableSchema } from '../../../src/shared/mobile-web/task-project-table-contract' +import { + MobileWebTaskProjectTableSchema, + type MobileWebTaskProjectTable, + type MobileWebTaskProjectTablePayload +} from '../../../src/shared/mobile-web/task-project-table-contract' import type { HostTaskProjectReadOperations } from './host-task-project-read-operations' +import { + nativeHostTaskProjectReadOperations, + projectResult +} from './native-host-task-project-read-operations' +import type { RpcRequestSender } from '../transport/rpc-client' +type ProjectTableWindow = { + data: MobileWebTaskProjectTable + nextRowOffset?: number +} + +/** Same reads as the native app, except the table arrives one row window at a time because the + * whole table can exceed the bridge envelope. */ export function webHostTaskProjectReadOperations( - client: MobileWebBridgeClient + client: RpcRequestSender ): HostTaskProjectReadOperations { return { - listAccessible: (host) => client.task.listProjects({ host }), - async listViews(project) { - return (await client.task.listProjectViews(project)).views - }, - resolveRef: (payload) => client.task.resolveProjectRef(payload), - async loadTable(payload) { - const first = await client.task.projectTablePage(payload) - if (!first.project || !first.selectedView || first.totalCount === undefined) { - throw new Error('Project table metadata is unavailable') - } - const rows = [...first.rows] - let cursor = first.nextCursor - while (cursor) { - const next = await client.task.projectTablePage({ ...payload, cursor }) - rows.push(...next.rows) - cursor = next.nextCursor - } - return MobileWebTaskProjectTableSchema.parse({ - project: first.project, - selectedView: first.selectedView, - totalCount: first.totalCount, - parentFieldDropped: first.parentFieldDropped, - rows - }) - }, - loadItemDetail: (payload) => client.task.loadProjectItemDetail(payload), - async listItemLabels(payload) { - return (await client.task.listProjectItemLabels(payload)).labels - }, - async listItemAssignableUsers(payload) { - return (await client.task.listProjectItemAssignableUsers(payload)).users - }, - async listIssueTypes(payload) { - return (await client.task.listProjectIssueTypes(payload)).types - } + ...nativeHostTaskProjectReadOperations(client), + loadTable: (payload) => loadWindowedTable(client, payload) } } + +async function loadWindowedTable( + client: RpcRequestSender, + payload: Omit +): Promise { + const rows: MobileWebTaskProjectTable['rows'] = [] + let table: MobileWebTaskProjectTable | null = null + let rowOffset: number | undefined = 0 + while (rowOffset !== undefined) { + const page: ProjectTableWindow = await projectResult( + client.sendRequest( + 'mobileWeb.tasks.projectTable', + { + owner: payload.owner, + host: payload.host, + ownerType: payload.ownerType, + projectNumber: payload.number, + viewId: payload.viewId, + queryOverride: payload.queryOverride, + rowOffset + }, + { timeoutMs: 60_000 } + ) + ) + table = page.data + rows.push(...page.data.rows) + // A window that added nothing cannot be followed by one that does, so stop rather than spin. + rowOffset = page.data.rows.length > 0 ? page.nextRowOffset : undefined + } + return MobileWebTaskProjectTableSchema.parse({ ...table, rows }) +} diff --git a/mobile/src/tasks/web-host-task-provider-write-operations.ts b/mobile/src/tasks/web-host-task-provider-write-operations.ts deleted file mode 100644 index 33cf190e7f7..00000000000 --- a/mobile/src/tasks/web-host-task-provider-write-operations.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { MobileWebBridgeClient } from '../../../src/mobile-web/src/mobile-web-bridge-client' -import type { HostTaskProviderWriteOperations } from './host-task-provider-write-operations' - -export function webHostTaskProviderWriteOperations( - client: MobileWebBridgeClient -): HostTaskProviderWriteOperations { - return { - createIssue: (payload) => client.task.createProviderIssue(payload), - async updateIssueSource(repoId, preference) { - await client.task.updateIssueSource({ repoId, preference }) - } - } -} diff --git a/mobile/src/tasks/web-host-task-read-operations.ts b/mobile/src/tasks/web-host-task-read-operations.ts deleted file mode 100644 index 6bcb64c6734..00000000000 --- a/mobile/src/tasks/web-host-task-read-operations.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { MobileWebBridgeClient } from '../../../src/mobile-web/src/mobile-web-bridge-client' -import type { TuiAgent } from '../../../src/shared/tui-agent' -import { normalizeWorkspaceAgent } from './workspace-agent-selection' -import type { HostTaskBootstrap, HostTaskReadOperations } from './host-task-read-operations' - -export function webHostTaskReadOperations(client: MobileWebBridgeClient): HostTaskReadOperations { - return { - async bootstrap() { - return webBootstrap(await client.task.bootstrap()) - }, - async listRepositories() { - return (await client.task.repositories()).repositories - }, - loadLinearContext: () => client.task.linearContext(), - async resolveGitHubRepoSlug(repoId) { - return (await client.task.resolveRepoSlug({ repoId })).repository - } - } -} - -function webBootstrap( - bootstrap: Awaited> -): HostTaskBootstrap { - const defaultTuiAgent = normalizeWorkspaceAgent(bootstrap.settings.defaultTuiAgent) - return { - ...bootstrap, - settings: { - ...bootstrap.settings, - defaultTuiAgent, - disabledTuiAgents: bootstrap.settings.disabledTuiAgents?.flatMap((agent) => { - const normalized = normalizeWorkspaceAgent(agent) - return normalized && normalized !== 'blank' ? [normalized as TuiAgent] : [] - }) - } - } -}