Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
72bbb82
feat: add SSH ops panel to sidebar
zjy-web222 Aug 16, 2026
e3b186e
feat: add git graph swimlane panel
zjy-web222 Aug 16, 2026
3a60627
feat: add LAN remote preview (mobile read-only)
zjy-web222 Aug 16, 2026
1c9b8b8
refactor: harden and modularize the three new panels
zjy-web222 Aug 16, 2026
3ed9791
feat: push LAN preview summary to IM via DingTalk MCP
zjy-web222 Aug 16, 2026
68a2aa7
feat: add smart routing gateway config UI and IPC
zjy-web222 Aug 16, 2026
c44bd10
Merge remote-tracking branch 'origin/agent/desktop-pet-assistant' int…
zjy-web222 Aug 16, 2026
3439464
fix: harden LAN preview packaging, git graph async, router gateway fi…
zjy-web222 Aug 16, 2026
902df4b
feat: bundle slide-maker and academic templates as ipollowork.deck/ac…
zjy-web222 Aug 16, 2026
6e75925
feat: add scheduled tasks panel with cron support
zjy-web222 Aug 16, 2026
5f1b6b6
Merge branch 'main' into feat/client-upgrades
zjy-web222 Aug 16, 2026
c80b2ab
fix(scheduled-tasks): persist last-run status and bound cron next-run…
zjy-web222 Aug 16, 2026
9e3af7e
refactor(scheduled-tasks): drop type assertions in cron parser
zjy-web222 Aug 16, 2026
99688f4
fix(scheduled-tasks): validate cron tokens, reject invalid expression…
zjy-web222 Aug 16, 2026
b1f5254
test(scheduled-tasks): add cron engine and ticker coverage
zjy-web222 Aug 16, 2026
05ea504
feat: restore projects and prepare multi-engine providers
Devin-DigitalID Aug 16, 2026
cf7fe7e
Merge pull request #347 from Devin-AXIS/agent/project-foundation
Devin-AXIS Aug 16, 2026
4224e6a
refactor: standardize conversation engine protocol
Devin-DigitalID Aug 16, 2026
5dd8875
Merge pull request #348 from Devin-AXIS/agent/conversation-engine-pro…
Devin-AXIS Aug 16, 2026
018e4a7
fix(plugin-package): migrate legacy schemaVersion=1 manifests and ins…
zjy-web222 Aug 16, 2026
48f10f4
fix(marketplace): degrade gracefully when cloud marketplace API retur…
zjy-web222 Aug 16, 2026
c8b6c27
feat(plugin-packages): bundle full lark and dingtalk skill sets
zjy-web222 Aug 16, 2026
859cdaa
feat(templates): bundle 8 localized HTML slides templates (zh)
zjy-web222 Aug 16, 2026
ef3e7bc
Merge remote-tracking branch 'upstream/main' into feat/client-upgrades
zjy-web222 Aug 17, 2026
5c842f6
chore: map personal work email to GitHub noreply via .mailmap
zjy-web222 Aug 17, 2026
9a20e4b
fix(privacy): remove personal identity from template samples and pet …
zjy-web222 Aug 17, 2026
69de8ba
fix(templates): restore slide-maker provenance in deck manifests
zjy-web222 Aug 17, 2026
895f797
fix(security): harden desktop IPC, pet window origin, and companion r…
zjy-web222 Aug 17, 2026
2e7c20e
chore: prepare v0.21.3 release
zjy-web222 Aug 17, 2026
e8753d3
fix(desktop): stage @ipollowork/types/workspace module for packaged s…
zjy-web222 Aug 17, 2026
c9057a0
fix(scheduled-tasks): openNew should start a draft, not reset to null
zjy-web222 Aug 17, 2026
fc40f19
fix(plugin-engine): fall back to legacy resource paths when reading a…
zjy-web222 Aug 17, 2026
326d2cd
fix(pet): use configured nickname and keep the companion on-screen
zjy-web222 Aug 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
2 changes: 2 additions & 0 deletions .mailmap
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Map personal work email to GitHub noreply so it is not exposed on public surfaces.
Jason880902 <281131931+Jason880902@users.noreply.github.com> 韩大哥 <cjiasheng@linewell.com>
2 changes: 1 addition & 1 deletion apps/app/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@ipollowork/app",
"private": true,
"version": "0.21.2",
"version": "0.21.3",
"type": "module",
"scripts": {
"test": "bun test --isolate tests/",
Expand Down
53 changes: 18 additions & 35 deletions apps/app/scripts/session-render-state.test.ts
Original file line number Diff line number Diff line change
@@ -1,63 +1,46 @@
import { describe, expect, it } from "bun:test";
import type { UIMessage } from "ai";

import type { iPolloWorkSessionSnapshot } from "../src/app/lib/ipollowork-server";
import type { ConversationSnapshot } from "../src/react-app/domains/session/engine/conversation-engine";
import {
deriveRenderedSessionMessages,
resolveRenderedSessionSnapshot,
} from "../src/react-app/domains/session/surface/session-render-state";
import {
applyRevertCursor,
reconcileTranscriptMessages,
resolveForkBoundaryId,
} from "../src/react-app/domains/session/sync/transcript-reconcile";
import { describeOpencodeSessionError } from "../src/react-app/domains/session/sync/usechat-adapter";
import { resolveOpenCodeForkBoundaryId } from "../src/react-app/domains/session/engine/opencode-conversation-mapper";
import { describeOpencodeSessionError } from "../src/react-app/domains/session/engine/opencode-message-adapter";

function snapshotWithMessages(
messages: Array<{ id: string; role: "user" | "assistant"; text: string; created?: number }>,
sessionId = "ses_test",
): iPolloWorkSessionSnapshot {
): ConversationSnapshot {
return {
session: {
id: sessionId,
parentID: undefined,
title: "Test session",
time: { created: 1, updated: 2 },
share: undefined,
version: "0",
},
messages: messages.map((message, index) => ({
info: {
id: message.id,
role: message.role,
sessionID: sessionId,
time: { created: message.created ?? index + 1 },
},
parts: [
{
id: `part_${message.id}`,
type: "text",
text: message.text,
sessionID: sessionId,
messageID: message.id,
},
],
})),
messages: messages.map((message, index) =>
uiMessage(message.id, message.role, message.text, message.created ?? index + 1),
),
todos: [],
status: { type: "idle" },
} as unknown as iPolloWorkSessionSnapshot;
};
}

function uiMessage(id: string, role: "user" | "assistant", text: string, created?: number): UIMessage {
return {
id,
role,
...(typeof created === "number" ? { metadata: { opencode: { created } } } : {}),
...(typeof created === "number" ? { metadata: { ipollowork: { created } } } : {}),
parts: [{ type: "text", text, state: "done" }],
};
}

function snapshotWithText(text: string, sessionId = "ses_test"): iPolloWorkSessionSnapshot {
function snapshotWithText(text: string, sessionId = "ses_test"): ConversationSnapshot {
return snapshotWithMessages([{ id: "msg_user", role: "user", text }], sessionId);
}

Expand Down Expand Up @@ -378,7 +361,7 @@ describe("deriveRenderedSessionMessages with revert", () => {
{ id: "msg_3", role: "user", text: "turn two" },
{ id: "msg_4", role: "assistant", text: "answer two" },
]);
(snapshot.session as { revert?: { messageID: string } }).revert = { messageID: "msg_3" };
snapshot.session.revertMessageId = "msg_3";

const rendered = deriveRenderedSessionMessages({
transcriptState: [],
Expand All @@ -389,7 +372,7 @@ describe("deriveRenderedSessionMessages with revert", () => {
});
});

describe("resolveForkBoundaryId", () => {
describe("resolveOpenCodeForkBoundaryId", () => {
const transcript = [
uiMessage("msg_1", "user", "turn one"),
uiMessage("msg_2", "assistant", "answer one"),
Expand All @@ -398,16 +381,16 @@ describe("resolveForkBoundaryId", () => {
];

it("returns the next message so the fork includes the branch point", () => {
expect(resolveForkBoundaryId(transcript, "msg_2")).toBe("msg_3");
expect(resolveForkBoundaryId(transcript, "msg_3")).toBe("msg_4");
expect(resolveOpenCodeForkBoundaryId(transcript, "msg_2")).toBe("msg_3");
expect(resolveOpenCodeForkBoundaryId(transcript, "msg_3")).toBe("msg_4");
});

it("returns null when branching at the last message (fork everything)", () => {
expect(resolveForkBoundaryId(transcript, "msg_4")).toBeNull();
expect(resolveOpenCodeForkBoundaryId(transcript, "msg_4")).toBeNull();
});

it("returns null for unknown ids instead of corrupting the boundary", () => {
expect(resolveForkBoundaryId(transcript, "msg_missing")).toBeNull();
expect(resolveOpenCodeForkBoundaryId(transcript, "msg_missing")).toBeNull();
});

it("skips synthetic session-error messages when picking the boundary", () => {
Expand All @@ -416,8 +399,8 @@ describe("resolveForkBoundaryId", () => {
uiMessage("session-error:msg_2", "assistant", "boom"),
...transcript.slice(2),
];
expect(resolveForkBoundaryId(withSynthetic, "msg_2")).toBe("msg_3");
expect(resolveOpenCodeForkBoundaryId(withSynthetic, "msg_2")).toBe("msg_3");
// Branching at the synthetic message itself falls through to the next real message.
expect(resolveForkBoundaryId(withSynthetic, "session-error:msg_2")).toBe("msg_3");
expect(resolveOpenCodeForkBoundaryId(withSynthetic, "session-error:msg_2")).toBe("msg_3");
});
});
63 changes: 62 additions & 1 deletion apps/app/src/app/lib/desktop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ import type {
WorkspaceList,
} from "./desktop-types";
import type { BrowserPanelTab } from "./desktop-types";
import type {
ScheduledTask,
ScheduledTaskCreateInput,
ScheduledTaskLogEntry,
ScheduledTaskUpdatePatch,
} from "@/react-app/domains/session/scheduled-tasks/scheduled-task";

export const LOCAL_IMAGE_FILE_EXTENSIONS = ["avif", "bmp", "gif", "ico", "jpeg", "jpg", "png", "svg", "webp"];
export const LOCAL_IMAGE_FILE_FILTERS = [{ name: "图片文件", extensions: LOCAL_IMAGE_FILE_EXTENSIONS }];
Expand All @@ -53,6 +59,17 @@ export type BrowserProxyState = {
proxy: { rules: string; authenticated: boolean } | null;
};

export type LanPreviewState = {
enabled: boolean;
port: number;
addresses: string[];
code: string | null;
codeExpiresAt: number;
sessionCount: number;
pendingChallengeCount?: number;
error?: string;
};

// ---------------------------------------------------------------------------
// Electron bridge surface
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -154,13 +171,51 @@ declare global {
onPanelClosed?: (callback: () => void) => () => void;
};
terminal?: {
create?: (options: { cwd: string; cols: number; rows: number }) => Promise<{ terminalId: string }>;
create?: (options: {
cwd: string;
cols: number;
rows: number;
/** Optional argv for the shell. When present the shell runs this
command instead of opening an interactive prompt, e.g.
`["ssh", "user@host"]` for the ops panel. */
command?: string[];
/** Optional explicit shell/executable path. */
shell?: string;
}) => Promise<{ terminalId: string }>;
write?: (terminalId: string, data: string) => Promise<void>;
resize?: (terminalId: string, cols: number, rows: number) => Promise<void>;
kill?: (terminalId: string) => Promise<void>;
onData?: (callback: (payload: { terminalId: string; data: string }) => void) => () => void;
onExit?: (callback: (payload: { terminalId: string; exitCode: number | null; signal?: number }) => void) => () => void;
};
ssh?: {
listHosts?: () => Promise<{ hosts: string[]; configPath: string }>;
};
git?: {
graph?: (options: { cwd: string; maxCommits?: number }) => Promise<
| { ok: true; repoRoot: string; count: number; totalCount: number | null; truncated: boolean; isRepo: true; commits: { sha: string; parents: string[] }[]; refs: { sha: string; refname: string; head: boolean }[]; headShas: string[] }
| { ok: false; isRepo: boolean; error: string }
>;
};
scheduledTasks?: {
list?: () => Promise<ScheduledTask[]>;
create?: (input: ScheduledTaskCreateInput) => Promise<ScheduledTask>;
update?: (id: string, patch: ScheduledTaskUpdatePatch) => Promise<ScheduledTask | null>;
setEnabled?: (id: string, enabled: boolean) => Promise<ScheduledTask | null>;
remove?: (id: string) => Promise<boolean>;
runNow?: (id: string) => Promise<ScheduledTask | null>;
logs?: (id: string) => Promise<ScheduledTaskLogEntry[]>;
preview?: (cron: string) => Promise<{ valid: boolean; nextRunAt: number | null }>;
onChanged?: (callback: (payload: { type: string; taskId?: string }) => void) => () => void;
};
lanPreview?: {
getState?: () => Promise<LanPreviewState>;
setEnabled?: (enabled: boolean) => Promise<LanPreviewState>;
regenerateCode?: () => Promise<LanPreviewState>;
disconnectAll?: () => Promise<LanPreviewState>;
pushToIm?: (options: { mcpUrl: string }) => Promise<{ ok: boolean; tool?: string; error?: string }>;
onStateChanged?: (callback: (state: LanPreviewState) => void) => () => void;
};
hyperframes?: {
start?: (options: { workspaceRoot: string; sessionId: string; projectDirectory: string; port: number }) => Promise<{ ok: boolean; port?: number; reused?: boolean }>;
stop?: (sessionId: string, options?: { keepWarm?: boolean }) => Promise<{ ok: boolean }>;
Expand Down Expand Up @@ -576,6 +631,9 @@ const {
updaterEnvironment,
readOpencodeConfig,
writeOpencodeConfig,
routerGatewayGetConfig,
routerGatewayWriteConfig,
routerGatewayStatus,
resetiPolloWorkState,
resetOpencodeCache,
opencodeMcpAuth,
Expand Down Expand Up @@ -644,6 +702,9 @@ export {
updaterEnvironment,
readOpencodeConfig,
writeOpencodeConfig,
routerGatewayGetConfig,
routerGatewayWriteConfig,
routerGatewayStatus,
resetiPolloWorkState,
resetOpencodeCache,
opencodeMcpAuth,
Expand Down
71 changes: 0 additions & 71 deletions apps/app/src/app/lib/ipollowork-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -696,27 +696,6 @@ export type iPolloWorkReloadEvent = {
timestamp: number;
};

export type iPolloWorkSessionGroupDefinition = {
id: string;
label: string;
};

export type iPolloWorkSessionGroupState = {
groups: iPolloWorkSessionGroupDefinition[];
assignments: Record<string, string>;
};

export type iPolloWorkSessionGroupEvent = {
id: string;
seq: number;
workspaceId: string;
type: "session_groups.updated";
action: "created" | "updated" | "deleted" | "assigned" | "reordered" | "imported";
groupId?: string;
sessionId?: string;
timestamp: number;
};

// Fallback for explicit server-mode URL derivation. Desktop local workers replace this
// with the persisted runtime-discovered port once the host reports it.
export const DEFAULT_IPOLLOWORK_SERVER_PORT = 8787;
Expand Down Expand Up @@ -1416,56 +1395,6 @@ export function createiPolloWorkServerClient(options: { baseUrl: string; token?:
{ token, hostToken, timeoutMs: timeouts.sessionRead },
);
},
getSessionGroups: (workspaceId: string) =>
requestJson<{ state: iPolloWorkSessionGroupState; updatedAt: number | null }>(
baseUrl,
`/workspace/${encodeURIComponent(workspaceId)}/session-groups`,
{ token, hostToken, timeoutMs: timeouts.sessionRead },
),
putSessionGroups: (workspaceId: string, state: iPolloWorkSessionGroupState) =>
requestJson<{ state: iPolloWorkSessionGroupState; updatedAt: number }>(
baseUrl,
`/workspace/${encodeURIComponent(workspaceId)}/session-groups`,
{ token, hostToken, method: "PUT", body: { state }, timeoutMs: timeouts.config },
),
createSessionGroup: (workspaceId: string, input: { id?: string; label: string }) =>
requestJson<{ state: iPolloWorkSessionGroupState; updatedAt: number }>(
baseUrl,
`/workspace/${encodeURIComponent(workspaceId)}/session-groups`,
{ token, hostToken, method: "POST", body: input, timeoutMs: timeouts.config },
),
reorderSessionGroups: (workspaceId: string, groupIds: string[]) =>
requestJson<{ state: iPolloWorkSessionGroupState; updatedAt: number }>(
baseUrl,
`/workspace/${encodeURIComponent(workspaceId)}/session-groups/reorder`,
{ token, hostToken, method: "PATCH", body: { groupIds }, timeoutMs: timeouts.config },
),
assignSessionGroup: (workspaceId: string, sessionId: string, groupId: string | null) =>
requestJson<{ state: iPolloWorkSessionGroupState; updatedAt: number }>(
baseUrl,
`/workspace/${encodeURIComponent(workspaceId)}/session-groups/assignments/${encodeURIComponent(sessionId)}`,
{ token, hostToken, method: "PATCH", body: { groupId }, timeoutMs: timeouts.config },
),
renameSessionGroup: (workspaceId: string, groupId: string, label: string) =>
requestJson<{ state: iPolloWorkSessionGroupState; updatedAt: number }>(
baseUrl,
`/workspace/${encodeURIComponent(workspaceId)}/session-groups/${encodeURIComponent(groupId)}`,
{ token, hostToken, method: "PATCH", body: { label }, timeoutMs: timeouts.config },
),
removeSessionGroup: (workspaceId: string, groupId: string) =>
requestJson<{ state: iPolloWorkSessionGroupState; updatedAt: number }>(
baseUrl,
`/workspace/${encodeURIComponent(workspaceId)}/session-groups/${encodeURIComponent(groupId)}`,
{ token, hostToken, method: "DELETE", timeoutMs: timeouts.config },
),
listSessionGroupEvents: (workspaceId: string, options?: { since?: number }) => {
const query = typeof options?.since === "number" ? `?since=${options.since}` : "";
return requestJson<{ items: iPolloWorkSessionGroupEvent[]; cursor?: number }>(
baseUrl,
`/workspace/${encodeURIComponent(workspaceId)}/session-groups/events${query}`,
{ token, hostToken },
);
},
getSession: (workspaceId: string, sessionId: string) =>
requestJson<{ item: Session }>(
baseUrl,
Expand Down
Loading
Loading