Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
8cb219e
feat(desktop): scaffold the Pythinker desktop app package
elkaix Aug 15, 2026
611ae7d
feat(desktop): supervise the local Pythinker server process
elkaix Aug 15, 2026
f0a4447
feat(desktop): add tray-owned window and quit lifecycle
elkaix Aug 15, 2026
e3c8971
feat(desktop): add the Electron main process with splash and tray
elkaix Aug 15, 2026
a791852
feat(desktop): add the mascot brand assets
elkaix Aug 15, 2026
45c4efd
feat(desktop): stage the server runtime for packaging
elkaix Aug 15, 2026
8cbb9d8
feat(desktop): add the macOS signing and notarization pipeline
elkaix Aug 15, 2026
6e301f9
test(desktop): cover the server supervisor
elkaix Aug 15, 2026
b3ba232
test(desktop): cover window lifecycle and packaged runtime checks
elkaix Aug 15, 2026
de27b17
test(desktop): cover packaging configuration and release preflight
elkaix Aug 15, 2026
7a2d9c1
docs(desktop): document development, packaging, and release
elkaix Aug 15, 2026
949df66
chore: register the desktop app in the workspace
elkaix Aug 15, 2026
3cbbd55
fix(cli): run on Node 20+ by gating the FFI re-exec on Node 26.4
elkaix Aug 15, 2026
148188d
feat(web): inset the sidebar header on macOS desktop and add the masc…
elkaix Aug 15, 2026
342f832
fix(agent-core): skip invalid sessions during listing instead of failing
elkaix Aug 15, 2026
e7b81bd
feat(web): periwinkle accent, desktop translucency, and a live mascot…
elkaix Aug 15, 2026
b84506e
fix(web): dedupe streamed transcript copies and keep paragraph breaks
elkaix Aug 15, 2026
5659c33
feat(web): standardize the periwinkle accent and show the mascot on w…
elkaix Aug 15, 2026
b5457c7
fix(protocol): add synthetic prompt lifecycle events to the event schema
elkaix Aug 15, 2026
58d17ac
fix(web): keep scoped descendants inside global desktop-platform sele…
elkaix Aug 15, 2026
37b3cd5
fix(protocol): add question and approval lifecycle events to the schema
elkaix Aug 15, 2026
32c82a5
feat(web): desktop sidebar, collapse choreography, and empty-state halo
elkaix Aug 15, 2026
5cbfd00
feat(web): reference typography for the desktop app
elkaix Aug 15, 2026
b13ff20
feat(desktop): auto-update, update settings UI, and telemetry
elkaix Aug 15, 2026
821f135
ci: add the desktop macOS release workflow
elkaix Aug 15, 2026
b9f8e4d
feat(site): add the Pythinker Desktop showcase section
elkaix Aug 15, 2026
8abe06d
chore: add changesets for the desktop app branch
elkaix Aug 15, 2026
a7074c9
Merge branch 'main' into feat/desktop-app
elkaix Aug 15, 2026
2dfd2db
feat(cli): paint the update notice in the status bar in the warning c…
elkaix Aug 15, 2026
8ae5114
fix: address PR review findings
elkaix Aug 15, 2026
d897070
fix: address CodeRabbit review findings
elkaix Aug 15, 2026
47aa019
chore: release the SDK event union additions as a major bump
elkaix Aug 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/desktop-design-port.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@pymodel/pythinker-code": minor
---

Match the desktop app's sidebar, collapse animation, empty-state visuals, and typography to the desktop design.
5 changes: 5 additions & 0 deletions .changeset/desktop-update-settings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@pymodel/pythinker-code": minor
---

Add a Desktop app section to web settings with automatic updates on by default, a manual update check, and a restart-to-update action.
5 changes: 5 additions & 0 deletions .changeset/journal-question-events.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@pymodel/pythinker-code": patch
---

Fix sessions failing to load with an invalid event journal error after questions or approvals were resolved.
5 changes: 5 additions & 0 deletions .changeset/node-20-support.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@pymodel/pythinker-code": patch
---

Run on Node 20 and newer by only re-executing for FFI support on Node 26.4+.
5 changes: 5 additions & 0 deletions .changeset/sdk-event-union.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@pymodel/pythinker-code-sdk": major
---

Add question, approval, and prompt lifecycle events to the SDK session event types.
5 changes: 5 additions & 0 deletions .changeset/session-listing-resilience.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@pymodel/pythinker-code": patch
---

Skip invalid sessions during listing instead of failing the whole list.
5 changes: 5 additions & 0 deletions .changeset/status-bar-update-notice.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@pymodel/pythinker-code": patch
---

Highlight the update notice in the terminal status bar with the warning color.
5 changes: 5 additions & 0 deletions .changeset/web-brand-refresh.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@pymodel/pythinker-code": minor
---

Refresh the web UI accent color and show the animated mascot on workflow cards, the activity spinner, and the empty state.
5 changes: 5 additions & 0 deletions .changeset/web-transcript-dedupe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@pymodel/pythinker-code": patch
---

Fix duplicated streamed transcript copies and lost paragraph breaks in the web UI.
74 changes: 74 additions & 0 deletions .github/workflows/desktop-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
name: Desktop Release

on:
push:
tags: ['desktop-v*']
workflow_dispatch: {}

permissions:
contents: write

concurrency:
group: desktop-release-${{ github.ref }}
cancel-in-progress: false

jobs:
mac:
runs-on: macos-15
steps:
- name: Checkout
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # pinned from v4
with:
fetch-depth: 0
persist-credentials: true

- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # pinned from v6

- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # pinned from v6
with:
node-version-file: .nvmrc
cache: pnpm

- run: pnpm install --frozen-lockfile

- name: Stamp desktop version for tag builds
if: startsWith(github.ref, 'refs/tags/desktop-v')
env:
TAG_NAME: ${{ github.ref_name }}
run: |
export DESKTOP_VERSION="${TAG_NAME#desktop-v}"
node -e 'const fs = require("node:fs"); const path = "apps/desktop/package.json"; const packageJson = JSON.parse(fs.readFileSync(path, "utf8")); packageJson.version = process.env.DESKTOP_VERSION; fs.writeFileSync(path, `${JSON.stringify(packageJson, null, 2)}\n`);'

- name: Build workspace
run: pnpm --workspace-root run build

- name: Stage desktop runtime
working-directory: apps/desktop
run: node --import tsx scripts/stage-runtime.ts

# On a desktop-v* tag, --publish always creates or updates the draft-or-release
# for that tag; contents: write makes GITHUB_TOKEN sufficient.
# Without Developer ID signing secrets, electron-builder publishes an
# ad-hoc/self-signed app. macOS auto-update will not accept unsigned updates,
# but this still proves packaging and the feed shape.
- name: Package and publish desktop release
working-directory: apps/desktop
run: pnpm exec electron-builder --mac dmg zip --publish always
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CSC_LINK: ${{ secrets.MAC_CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}

- name: Upload macOS artifacts for manual runs
if: github.event_name == 'workflow_dispatch'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # pinned from v7
with:
name: desktop-macos
path: |
apps/desktop/dist/*.dmg
apps/desktop/dist/*.zip
apps/desktop/dist/latest-mac.yml
if-no-files-found: error
75 changes: 75 additions & 0 deletions apps/desktop/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Pythinker Desktop

The desktop app supervises the existing loopback Web Host and keeps it alive from the system tray when its window is closed.

## Development

Install dependencies, then use the single desktop development command. It builds the Host and client packages, Web frontend, and Electron main process before launching the application:

```sh
pnpm run dev:desktop
```

Closing the window hides it. Use the tray menu to restore the window or quit the application. Explicit quit waits for the Host process to stop and escalates termination after the bounded Host grace period.

The desktop app accepts only the readiness URL emitted by `pythinker server run` for `127.0.0.1` or `localhost`. Navigation stays on that origin; HTTP and HTTPS links open in the system browser.

Native chrome follows the host platform. macOS uses a frameless inset title bar, traffic lights, and sidebar vibrancy; its collapsed sidebar is 90px wide, with centered controls whose top edge aligns with the expanded logo row below the traffic lights. Windows retains its system frame, shadow, resize and Snap behavior, and Windows 11 rounded corners while a hidden title bar places the native caption buttons in the Session header's first row; the Windows sidebar has no traffic-light inset. The empty part of that row remains draggable, its controls remain clickable, and a resident drag band covers the same row when no Session header is visible. Windows acrylic and macOS vibrancy reach only the sidebar, while conversation and details stay opaque. Linux keeps a frameless window and an opaque sidebar fallback.

## Packaging

The local packaging command performs the complete repository build, stages the Host's closed production dependency tree, and creates an unpacked application for the current platform. A separate manual build is not required:

```sh
pnpm run package:desktop
```

Packaged applications run the staged `@pymodel/pythinker-code` CLI in a separate process through Electron's Node mode. The application therefore retains the supervised-Host lifecycle without shipping a second Node executable. An `afterPack` check rejects the package before signing when the staged CLI entry or Web frontend entry is absent. Both macOS and Windows use the exact tracked `apps/desktop/build/icon.png` source; the repository does not preprocess or commit platform-specific icon variants.

### Signed macOS DMG

The macOS distribution command requires a valid `Developer ID Application` identity whose certificate and private key are both installed in the build user's Keychain. It also requires one complete notarization credential source. A Keychain profile keeps the app-specific password out of the repository and shell history:

```sh
xcrun notarytool store-credentials "pythinker-notary" --apple-id "<Apple ID>" --team-id "<Team ID>"
```

`notarytool` requests the secret interactively. Build the signed, hardened-runtime, notarized DMG with the stored profile:

```sh
APPLE_KEYCHAIN_PROFILE=pythinker-notary pnpm run dist:mac:desktop
```

An existing secrets file can supply `MAC_CERT_P12_BASE64`, `MACOS_SIGN_IDENTITY`, `CSC_KEY_PASSWORD`, `APPLE_ID`, `APPLE_APP_SPECIFIC_PASSWORD`, and `APPLE_TEAM_ID` without importing the certificate into the persistent Keychain:

```sh
node --env-file=/absolute/path/to/macos-signing-secrets.env --import tsx apps/desktop/scripts/release-mac.ts
```

Electron Builder imports that Base64 PKCS#12 certificate into its temporary Keychain and removes it when the build finishes. The wrapper keeps signing and notarization variables out of the repository-build and runtime-staging subprocesses, then passes them only to Electron Builder. The secrets file and its path are never tracked.

The release preflight runs before the repository build. It fails if the host is not macOS, the supplied identity is not a `Developer ID Application` identity, signing credentials are incomplete, signing discovery is disabled, or notarization credentials are missing or incomplete. Without the PKCS#12 group, it requires a usable `Developer ID Application` identity and private key in the Keychain. Instead of a Keychain profile, the command accepts the complete Apple ID group (`APPLE_ID`, `APPLE_APP_SPECIFIC_PASSWORD`, and `APPLE_TEAM_ID`) or App Store Connect API key group (`APPLE_API_KEY`, `APPLE_API_KEY_ID`, and `APPLE_API_ISSUER`).

After a successful build, mount the generated DMG and verify the installed application signature, Gatekeeper assessment, and stapled notarization ticket:

```sh
DMG_PATH="$(find apps/desktop/dist -maxdepth 1 -type f -name '*.dmg' -print -quit)"
MOUNT_POINT="$(mktemp -d)"
hdiutil attach "$DMG_PATH" -mountpoint "$MOUNT_POINT" -nobrowse -readonly
APP_PATH="$MOUNT_POINT/Pythinker.app"
codesign --verify --deep --strict --verbose=2 "$APP_PATH"
spctl --assess --type execute --verbose=4 "$APP_PATH"
xcrun stapler validate "$APP_PATH"
hdiutil detach "$MOUNT_POINT"
rmdir "$MOUNT_POINT"
```

## Known limitations

The first desktop assembly uses a loopback HTTP Host. The renderer and Host protocol remain unchanged so the application can replace the transport with the IPC carrier reserved by the GUI architecture without changing product features.

The signed installer path currently targets macOS. Windows and Linux packaging creates unpacked applications; their installer formats and distribution signing remain release work.

## Model Experience

The desktop shell does not add model-visible input. The reused Web profile continues to own its existing Web runtime context.
Binary file added apps/desktop/build/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
80 changes: 80 additions & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
{
"name": "@pymodel/pythinker-desktop",
"description": "Pythinker desktop application with tray-owned Host lifecycle",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "dist/main.js",
"scripts": {
"build": "tsc -p tsconfig.json && tsdown",
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tests/tsconfig.json",
"dev": "pnpm -C ../pythinker-code run build && tsc -p tsconfig.json && tsdown && electron .",
"package": "pnpm --workspace-root run build && node --import tsx scripts/stage-runtime.ts && electron-builder --dir",
"dist": "pnpm --workspace-root run build && node --import tsx scripts/stage-runtime.ts && electron-builder",
"dist:mac": "node --import tsx scripts/release-mac.ts"
},
"license": "MIT",
"devDependencies": {
"@pymodel/pythinker-telemetry": "workspace:*",
"@types/node": "^26.1.2",
"electron": "43.4.0",
"electron-builder": "26.15.3",
"electron-updater": "^6.8.9",
"tsdown": "0.22.3",
"typescript": "6.0.3",
"vitest": "4.1.9"
},
"build": {
"appId": "com.pythinker.desktop",
"productName": "Pythinker",
"publish": [
{
"provider": "github",
"owner": "PyModel",
"repo": "pythinker-code"
}
],
"afterPack": "./scripts/verify-packaged-runtime.ts",
"asar": true,
"files": [
"dist/main.js",
"dist/preload.cjs",
"package.json"
],
"extraResources": [
{
"from": "resources",
"to": "desktop-resources"
},
{
"from": "runtime-host/package.json",
"to": "host/package.json"
},
{
"from": "runtime-host/node_modules",
"to": "host/node_modules"
}
],
"mac": {
"category": "public.app-category.developer-tools",
"hardenedRuntime": true,
"icon": "build/icon.png",
"notarize": true,
"target": [
"dir"
]
},
Comment thread
elkaix marked this conversation as resolved.
"win": {
"icon": "build/icon.png",
"target": [
"dir"
]
},
"linux": {
"category": "Development",
"target": [
"dir"
]
}
}
}
Binary file added apps/desktop/resources/splash/idle-00.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/desktop/resources/splash/idle-01.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/desktop/resources/splash/idle-02.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/desktop/resources/splash/idle-03.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/desktop/resources/splash/idle-04.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/desktop/resources/splash/idle-05.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
41 changes: 41 additions & 0 deletions apps/desktop/resources/splash/splash.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
:root,
body {
width: 100%;
height: 100%;
margin: 0;
}

body {
display: grid;
place-items: center;
overflow: hidden;
background: transparent;
}

img {
width: 192px;
height: 208px;
object-fit: contain;
image-rendering: pixelated;
}
</style>
</head>
<body>
<img id="mascot" src="idle-00.png" alt="Pythinker">
<script>
const frames = Array.from({ length: 6 }, (_, index) => `idle-${String(index).padStart(2, '0')}.png`)
const mascot = document.getElementById('mascot')
let frame = 0
setInterval(() => {
frame = (frame + 1) % frames.length
mascot.src = frames[frame]
}, 140)
</script>
</body>
</html>
Binary file added apps/desktop/resources/tray/trayIdle@2x.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/desktop/resources/tray/trayRun-00@2x.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/desktop/resources/tray/trayRun-01@2x.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/desktop/resources/tray/trayRun-02@2x.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/desktop/resources/tray/trayRun-03@2x.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/desktop/resources/tray/trayRun-04@2x.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/desktop/resources/tray/trayRun-05@2x.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/desktop/resources/tray/trayRun-06@2x.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/desktop/resources/tray/trayRun-07@2x.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 10 additions & 0 deletions apps/desktop/runtime-host/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"name": "@pymodel/pythinker-desktop-runtime",
"description": "Dependency-only deploy root for the packaged desktop Host",
"version": "0.0.0",
"private": true,
"type": "module",
"dependencies": {
"@pymodel/pythinker-code": "workspace:*"
}
}
Loading
Loading