Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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-window-chrome.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@pymodel/pythinker-code': patch
---

Repaint the desktop chrome. The sidebar footer now carries a pill button, so Settings and the way back out of it match New Session and stay visible. The transcript reserves room for the floating work chips instead of letting them sit on the last line. Windows gets round window controls on the trailing edge, in place of the native caption buttons that could not be styled.
5 changes: 5 additions & 0 deletions .changeset/snapshot-stall.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@pymodel/pythinker-code': patch
---

Stop the session snapshot request from timing out on busy sessions. Each recorded event no longer pays a fresh file open and close, the watermark is read without waiting for pending writes, and the session list is scanned in parallel, so opening or refreshing a session stays fast even with a long history. This was most visible on Windows, where the per-event file cost is highest.
5 changes: 5 additions & 0 deletions .changeset/web-codex-login.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@pymodel/pythinker-code': minor
---

Add OpenAI Codex sign-in to the web and desktop app. The provider dialog now offers "Sign in with ChatGPT" next to the API-key form: the server runs the OAuth exchange, writes the credentials, and reports only which model it selected. When port 1455 is taken, the dialog asks for the redirect URL instead.
5 changes: 5 additions & 0 deletions .changeset/web-snapshot-recovery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@pymodel/pythinker-code': patch
---

Recover the web and desktop app when a session snapshot request fails. It is now retried with a growing delay instead of leaving the todo list and the sub-agent list frozen until a reload, and a failed task refresh reports itself rather than failing in silence.
18 changes: 18 additions & 0 deletions apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,24 @@ ipcMain.handle('pythinker:update:install', (event) => {
assertTrustedSender(event)
return quitAndInstallNow()
})
// Windows has no native caption buttons any more, so the renderer drives the
// window. `close()` is used rather than `destroy()` so the tray lifecycle still
// intercepts it and only hides the window.
ipcMain.handle('pythinker:window:minimize', (event) => {
assertTrustedSender(event)
mainWindow?.minimize()
})
ipcMain.handle('pythinker:window:toggle-maximize', (event) => {
assertTrustedSender(event)
const window = mainWindow
if (window === undefined) return
if (window.isMaximized()) window.unmaximize()
else window.maximize()
})
ipcMain.handle('pythinker:window:close', (event) => {
assertTrustedSender(event)
mainWindow?.close()
})
ipcMain.handle('pythinker:theme:set-source', (event, source: unknown) => {
assertTrustedSender(event)
if (source === 'dark' || source === 'light' || source === 'system') {
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ contextBridge.exposeInMainWorld('pythinkerDesktop', {
setAutoUpdate: (enabled: boolean) => ipcRenderer.invoke('pythinker:update:set-auto', enabled),
checkForUpdates: () => ipcRenderer.invoke('pythinker:update:check'),
quitAndInstall: () => ipcRenderer.invoke('pythinker:update:install'),
minimizeWindow: () => ipcRenderer.invoke('pythinker:window:minimize'),
toggleMaximizeWindow: () => ipcRenderer.invoke('pythinker:window:toggle-maximize'),
closeWindow: () => ipcRenderer.invoke('pythinker:window:close'),
setThemeSource: (source: 'dark' | 'light' | 'system') =>
ipcRenderer.invoke('pythinker:theme:set-source', source),
onUpdateState: (cb: (state: unknown) => void) => {
Expand Down
4 changes: 3 additions & 1 deletion apps/desktop/src/window-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@ export function windowAppearanceOptions(platform: NodeJS.Platform): BrowserWindo
}
if (platform === 'win32') {
return {
// No `titleBarOverlay`: the renderer draws macOS-style round controls in
// the top-right instead. `thickFrame` keeps the native resize border,
// shadow and drag-to-snap that `frame: false` would remove.
autoHideMenuBar: true,
titleBarStyle: 'hidden',
titleBarOverlay: { color: '#00000000', symbolColor: '#7f858f', height: 44 },
backgroundColor: '#161616',
hasShadow: true,
roundedCorners: true,
Expand Down
8 changes: 6 additions & 2 deletions apps/desktop/tests/window-appearance.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,22 @@ describe('desktop window appearance configuration', () => {
expect(opts['vibrancy']).toBe('sidebar')
})

it('keeps the Windows window configuration unchanged', () => {
it('leaves the Windows caption buttons to the renderer', () => {
const opts = windowAppearanceOptions('win32')

expect(opts).toMatchObject({
autoHideMenuBar: true,
titleBarStyle: 'hidden',
titleBarOverlay: { color: '#00000000', symbolColor: '#7f858f', height: 44 },
backgroundColor: '#161616',
hasShadow: true,
roundedCorners: true,
thickFrame: true,
})
// The overlay would draw native caption buttons on top of the round ones
// the renderer paints, so it has to stay off.
expect('titleBarOverlay' in opts).toBe(false)
// `thickFrame` without `frame: false` keeps the native resize border.
expect('frame' in opts).toBe(false)
})

it('keeps the Linux window configuration unchanged', () => {
Expand Down
2 changes: 1 addition & 1 deletion apps/pythinker-code/src/generated/dashboard-web-asset.ts

Large diffs are not rendered by default.

33 changes: 26 additions & 7 deletions apps/pythinker-code/src/utils/open-url.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,30 @@
import { execFile } from 'node:child_process';

export interface OpenUrlCommand {
readonly command: string;
readonly args: readonly string[];
}

/**
* Windows uses `rundll32` rather than `cmd /c start` because `cmd` reparses
* its arguments and cuts a URL at the first `&`, which strips OAuth query
* parameters.
*/
export function openUrlCommandFor(
url: string,
platform: NodeJS.Platform = process.platform,
): OpenUrlCommand {
switch (platform) {
case 'darwin':
return { command: 'open', args: [url] };
case 'win32':
return { command: 'rundll32', args: ['url.dll,FileProtocolHandler', url] };
default:
return { command: 'xdg-open', args: [url] };
}
}

export function openUrl(url: string): void {
const command: [string, string[]] =
process.platform === 'darwin'
? ['open', [url]]
: process.platform === 'win32'
? ['cmd', ['/c', 'start', '', url]]
: ['xdg-open', [url]];
execFile(command[0], command[1], () => {});
const { command, args } = openUrlCommandFor(url);
execFile(command, [...args], () => {});
}
29 changes: 29 additions & 0 deletions apps/pythinker-code/test/utils/open-url.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';

import { openUrlCommandFor } from '#/utils/open-url';

const oauthUrl =
'https://auth.openai.com/oauth/authorize?client_id=app_test&redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback&state=state';

describe('openUrlCommandFor', () => {
it('passes the complete OAuth URL to the Windows URL handler', () => {
expect(openUrlCommandFor(oauthUrl, 'win32')).toEqual({
command: 'rundll32',
args: ['url.dll,FileProtocolHandler', oauthUrl],
});
});

it('uses the native opener on macOS', () => {
expect(openUrlCommandFor(oauthUrl, 'darwin')).toEqual({
command: 'open',
args: [oauthUrl],
});
});

it('uses xdg-open on Linux', () => {
expect(openUrlCommandFor(oauthUrl, 'linux')).toEqual({
command: 'xdg-open',
args: [oauthUrl],
});
});
});
40 changes: 26 additions & 14 deletions apps/pythinker-web/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@ import { computed, nextTick, onMounted, onUnmounted, provide, ref, watch, watchE
import { useI18n } from 'vue-i18n';
import Sidebar from './components/Sidebar.vue';
import ResizeHandle from './components/ResizeHandle.vue';
import WindowControls from './components/WindowControls.vue';
import ConversationPane from './components/ConversationPane.vue';
import FilePreview, { type FileData } from './components/FilePreview.vue';
import ThinkingPanel from './components/ThinkingPanel.vue';
import AgentDetailPanel from './components/AgentDetailPanel.vue';
import SideChatPanel from './components/SideChatPanel.vue';
import DiffView from './components/DiffView.vue';
import type { AgentMember } from './types';
import type { AgentMember, FilePreviewRequest, ToolMedia } from './types';
import ModelPicker from './components/ModelPicker.vue';
import ProviderManager from './components/ProviderManager.vue';
import NewSessionDialog from './components/NewSessionDialog.vue';
Expand All @@ -32,7 +33,6 @@ import { useIsMobile } from './composables/useIsMobile';
import { useIsDark } from './composables/useIsDark';
import { useSettingsNav } from './composables/useSettingsNav';
import type { AppConfig, ThinkingLevel } from './api/types';
import type { FilePreviewRequest, ToolMedia } from './types';

const client = usePythinkerWebClient();
provide('resolveImage', client.resolveImageUrl);
Expand Down Expand Up @@ -728,6 +728,16 @@ async function handleRefreshProvider(id: string): Promise<void> {
await client.refreshProvider(id);
}

/** A Codex sign-in wrote its own provider entry; pull the new lists. */
async function handleProvidersChanged(): Promise<void> {
await Promise.all([
client.loadProviders(),
client.loadModels(),
client.checkAuth(),
client.loadConfig(),
]);
}

async function handleUpdateConfig(patch: Partial<AppConfig>): Promise<void> {
configSaving.value = true;
try {
Expand Down Expand Up @@ -920,6 +930,7 @@ function openPr(url: string): void {
<template>
<div class="app-shell">
<div class="windows-titlebar" aria-hidden="true"></div>
<WindowControls />
<section v-if="showAuthGate" class="auth-page">
<div class="auth-page-inner">
<PythinkerLogo size="lg" interactive class="auth-page-logo" />
Expand Down Expand Up @@ -1266,18 +1277,6 @@ function openPr(url: string): void {
@close="showModelPicker = false"
/>

<!-- Provider Manager overlay -->
<ProviderManager
v-if="showProviders"
:providers="client.providers.value"
:loading="providersLoading"
:unavailable="providersUnavailable"
@add="handleAddProvider($event)"
@refresh="handleRefreshProvider($event)"
@delete="handleDeleteProvider($event)"
@close="showProviders = false"
/>

<!-- New Session Dialog overlay (fallback cwd-typing path) -->
<NewSessionDialog
v-if="showNewSession"
Expand Down Expand Up @@ -1382,6 +1381,19 @@ function openPr(url: string): void {
@login="() => { showMobileSettings = false; openLogin(); }"
/>
</div>

<!-- Provider Manager overlay -->
<ProviderManager
v-if="showProviders"
:providers="client.providers.value"
:loading="providersLoading"
:unavailable="providersUnavailable"
@add="handleAddProvider($event)"
@refresh="handleRefreshProvider($event)"
@delete="handleDeleteProvider($event)"
@refresh-all="handleProvidersChanged()"
@close="showProviders = false"
/>
</div>
</template>

Expand Down
40 changes: 40 additions & 0 deletions apps/pythinker-web/src/api/daemon/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import type {
AppMessageRole,
AppModel,
AppProvider,
CodexLoginStart,
CodexLoginStatus,
ProviderRefreshResult,
AppSession,
AppConnector,
Expand Down Expand Up @@ -46,6 +48,7 @@ import {
toAppModel,
toAppProvider,
toAppQuestionRequest,
toCodexLoginStatus,
toAppSession,
toAppTask,
toWireApprovalResponse,
Expand All @@ -58,6 +61,8 @@ import {
} from './mappers';
import type {
WireAuthResult,
WireCodexLoginStart,
WireCodexLoginStatus,
WireBackgroundTask,
WireConfig,
WireEvent,
Expand Down Expand Up @@ -1149,6 +1154,41 @@ export class DaemonPythinkerWebApi implements PythinkerWebApi {
return toAppProvider(data);
}

async startCodexLogin(): Promise<CodexLoginStart> {
const data = await this.http.post<WireCodexLoginStart>('/auth/codex:start');
return {
loginId: data.login_id,
authorizeUrl: data.authorize_url,
loopback: data.loopback,
expiresAt: data.expires_at,
};
}

async getCodexLoginStatus(loginId: string): Promise<CodexLoginStatus> {
const data = await this.http.get<WireCodexLoginStatus>(
`/auth/codex/${encodeURIComponent(loginId)}`,
);
return toCodexLoginStatus(data);
}

async submitCodexLoginRedirect(
loginId: string,
redirectUrl: string,
): Promise<CodexLoginStatus> {
const data = await this.http.post<WireCodexLoginStatus>(
`/auth/codex/${encodeURIComponent(loginId)}:submit_code`,
{ redirect_url: redirectUrl },
);
return toCodexLoginStatus(data);
}

async cancelCodexLogin(loginId: string): Promise<CodexLoginStatus> {
const data = await this.http.post<WireCodexLoginStatus>(
`/auth/codex/${encodeURIComponent(loginId)}:cancel`,
);
return toCodexLoginStatus(data);
}

async refreshOAuthProviderModels(): Promise<ProviderRefreshResult> {
const data = await this.http.post<WireProviderRefreshResult>('/providers:refresh_oauth');
return {
Expand Down
11 changes: 11 additions & 0 deletions apps/pythinker-web/src/api/daemon/mappers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
AppGoal,
AppModel,
AppProvider,
CodexLoginStatus,
FsEntry,
AppMessage,
AppMessageContent,
Expand Down Expand Up @@ -39,6 +40,7 @@ import type {
WireMessageContent,
WireModel,
WirePromptSubmission,
WireCodexLoginStatus,
WireProvider,
WireQuestionAnswer,
WireQuestionItem,
Expand Down Expand Up @@ -712,6 +714,15 @@ export function toAppModel(wire: WireModel): AppModel {
};
}

export function toCodexLoginStatus(wire: WireCodexLoginStatus): CodexLoginStatus {
return {
loginId: wire.login_id,
state: wire.state,
defaultModel: wire.default_model,
message: wire.message,
};
}

export function toAppProvider(wire: WireProvider): AppProvider {
return {
id: wire.id,
Expand Down
14 changes: 14 additions & 0 deletions apps/pythinker-web/src/api/daemon/wire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,20 @@ export interface WireModel {
adaptive_thinking?: boolean;
}

export interface WireCodexLoginStart {
login_id: string;
authorize_url: string;
loopback: boolean;
expires_at: string;
}

export interface WireCodexLoginStatus {
login_id: string;
state: 'pending' | 'completed' | 'failed' | 'cancelled';
default_model?: string;
message?: string;
}

export interface WireProvider {
id: string;
type: string;
Expand Down
Loading
Loading