Skip to content

Commit 09de5fc

Browse files
committed
feat(web): prompt for desktop updates and tidy the sidebar header
Add a desktop-only update toast that offers install or skip, centre the settings button in the sidebar footer, and make the sidebar brand start a new session.
1 parent 13a0621 commit 09de5fc

10 files changed

Lines changed: 372 additions & 7 deletions

File tree

.changeset/desktop-update-toast.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pymodel/pythinker-code": minor
3+
---
4+
5+
Prompt for desktop updates with a toast that offers install or skip, centre the settings button in the sidebar footer, and start a new session when the sidebar brand is clicked.

apps/pythinker-web/src/App.vue

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import SettingsPane from './components/settings/SettingsPane.vue';
1818
import SessionsDialog from './components/SessionsDialog.vue';
1919
import AddWorkspaceDialog from './components/AddWorkspaceDialog.vue';
2020
import StatusPanel from './components/StatusPanel.vue';
21+
import UpdateToast from './components/UpdateToast.vue';
2122
import WarningToasts from './components/WarningToasts.vue';
2223
import MobileTopBar from './components/MobileTopBar.vue';
2324
import MobileSwitcherSheet from './components/MobileSwitcherSheet.vue';
@@ -1090,6 +1091,7 @@ function openPr(url: string): void {
10901091
@update-config="handleUpdateConfig($event)"
10911092
@login="loginFromSettings"
10921093
@open-onboarding="openOnboardingFromSettings"
1094+
@close="showSettings = false"
10931095
/>
10941096

10951097
<ConversationPane
@@ -1335,6 +1337,9 @@ function openPr(url: string): void {
13351337
<!-- Floating warnings / agent errors (e.g. a 403 from the model provider) -->
13361338
<WarningToasts :warnings="client.warnings.value" @dismiss="client.dismissWarning" />
13371339

1340+
<!-- Desktop update prompt (renders nothing in the browser) -->
1341+
<UpdateToast />
1342+
13381343
<!-- KAP/daemon debug panel (opt-in, ?debug=1) -->
13391344
<DebugPanel v-if="debugEnabled" />
13401345

@@ -1411,6 +1416,11 @@ function openPr(url: string): void {
14111416
height: env(titlebar-area-height, 44px);
14121417
-webkit-app-region: drag;
14131418
user-select: none;
1419+
/* Windows has no vibrancy, so the bar earns its own tone: a shade darker than
1420+
the shell with a hairline under it, which also frames the window buttons. */
1421+
background: color-mix(in srgb, var(--ink) 4%, var(--panel));
1422+
border-bottom: 1px solid var(--line);
1423+
box-sizing: border-box;
14141424
}
14151425
.auth-page {
14161426
flex: 1;

apps/pythinker-web/src/components/Sidebar.vue

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -464,10 +464,15 @@ onBeforeUnmount(() => {
464464
<div class="col" :style="{ width: colWidth + 'px' }">
465465
<!-- Header: logo + collapse (no hard border — flows into workspace list) -->
466466
<div class="ch">
467-
<div class="ch-brand">
468-
<PythinkerLogo size="sm" interactive />
467+
<button
468+
type="button"
469+
class="ch-brand"
470+
:title="t('sidebar.newSession')"
471+
@click.stop="emit('create')"
472+
>
473+
<PythinkerLogo size="sm" />
469474
<span class="ch-name">Pythinker Code</span>
470-
</div>
475+
</button>
471476
<button
472477
type="button"
473478
class="collapse-btn"
@@ -805,6 +810,17 @@ onBeforeUnmount(() => {
805810
min-width: 0;
806811
/* Take the row's slack so the action buttons group together on the right. */
807812
flex: 1;
813+
padding: 0;
814+
border: none;
815+
background: none;
816+
font: inherit;
817+
text-align: left;
818+
cursor: pointer;
819+
}
820+
.ch-brand:focus-visible {
821+
outline: 2px solid var(--blue);
822+
outline-offset: 2px;
823+
border-radius: 6px;
808824
}
809825
.ch-name {
810826
font-size: var(--ui-font-size);
@@ -1198,11 +1214,35 @@ onBeforeUnmount(() => {
11981214
text-align: left;
11991215
cursor: pointer;
12001216
}
1201-
/* The sessions-mode gear sits on the trailing edge; the settings-mode back
1202-
arrow keeps the leading edge, where a back control belongs. */
1217+
/* The sessions-mode gear centres in the sidebar and carries the New Session
1218+
card style; the settings-mode back arrow keeps the leading edge, where a
1219+
back control belongs. */
12031220
.settings-row.end {
1204-
justify-content: flex-end;
1205-
text-align: right;
1221+
width: calc(100% - 24px);
1222+
margin: 0 12px 10px;
1223+
justify-content: center;
1224+
gap: 10px;
1225+
padding: 9px 10px;
1226+
border: 1px solid var(--line);
1227+
border-radius: 10px;
1228+
background: var(--soft);
1229+
color: var(--ink);
1230+
font-family: var(--mono);
1231+
line-height: 1;
1232+
text-align: center;
1233+
white-space: nowrap;
1234+
}
1235+
.settings-row.end:hover {
1236+
background: color-mix(in srgb, var(--ink) 6%, var(--soft));
1237+
}
1238+
.settings-row.end:focus-visible {
1239+
outline-offset: 1px;
1240+
}
1241+
/* The back control is secondary next to the settings nav above it, so it sits
1242+
a step dimmer than the muted default until hovered. */
1243+
.settings-row:not(.end) {
1244+
color: color-mix(in srgb, var(--muted) 65%, transparent);
1245+
font-weight: 400;
12061246
}
12071247
.settings-row:hover { color: var(--ink); background: var(--soft); }
12081248
.settings-row:focus-visible {
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
<!-- apps/pythinker-web/src/components/UpdateToast.vue -->
2+
<!-- Desktop-only prompt: a new version is ready (or waiting to download).
3+
Unlike WarningToasts this never auto-dismisses — it asks a question. -->
4+
<script setup lang="ts">
5+
import { computed, onMounted, onUnmounted, ref } from 'vue';
6+
import { useI18n } from 'vue-i18n';
7+
8+
const { t } = useI18n();
9+
10+
const bridge = typeof window !== 'undefined' ? window.pythinkerDesktop : undefined;
11+
const state = ref<DesktopUpdateState>();
12+
const busy = ref(false);
13+
/** Versions the user skipped. Persisted per version so the prompt does not
14+
return for the same build; an unnamed version is skipped for the session. */
15+
const skipped = ref<string[]>(readSkipped());
16+
const SKIP_KEY = 'pythinker.update.skipped';
17+
18+
let removeListener: (() => void) | undefined;
19+
20+
function readSkipped(): string[] {
21+
try {
22+
const raw: unknown = JSON.parse(localStorage.getItem(SKIP_KEY) ?? '[]');
23+
return Array.isArray(raw) ? raw.filter((v): v is string => typeof v === 'string') : [];
24+
} catch {
25+
return [];
26+
}
27+
}
28+
29+
const visible = computed(() => {
30+
const current = state.value;
31+
if (current === undefined) return false;
32+
if (current.status !== 'downloaded' && !(current.status === 'available' && !current.autoUpdate)) {
33+
return false;
34+
}
35+
return !skipped.value.includes(current.version ?? '');
36+
});
37+
38+
const title = computed(() =>
39+
state.value?.version
40+
? t('update.availableVersion', { version: state.value.version })
41+
: t('update.available'),
42+
);
43+
44+
const primaryLabel = computed(() =>
45+
state.value?.status === 'downloaded' ? t('settings.desktop.restartToUpdate') : t('update.download'),
46+
);
47+
48+
async function primary(): Promise<void> {
49+
if (bridge === undefined || busy.value) return;
50+
busy.value = true;
51+
try {
52+
state.value =
53+
state.value?.status === 'downloaded' ? await bridge.quitAndInstall() : await bridge.checkForUpdates();
54+
} finally {
55+
busy.value = false;
56+
}
57+
}
58+
59+
function skip(): void {
60+
const next = [...skipped.value, state.value?.version ?? ''];
61+
skipped.value = next;
62+
try {
63+
localStorage.setItem(SKIP_KEY, JSON.stringify(next.filter((v) => v !== '')));
64+
} catch {
65+
// A blocked localStorage still skips for this session.
66+
}
67+
}
68+
69+
onMounted(() => {
70+
// Dev-only preview: the real updater stays disabled outside packaged builds,
71+
// so `?updateDemo=1` on the Vite dev server is the only way to see this.
72+
if (import.meta.env.DEV && new URLSearchParams(window.location.search).has('updateDemo')) {
73+
state.value = { status: 'downloaded', version: '0.0.0-dev', autoUpdate: true };
74+
return;
75+
}
76+
if (bridge === undefined) return;
77+
removeListener = bridge.onUpdateState((next) => {
78+
state.value = next;
79+
});
80+
void bridge.getUpdateState().then((next) => {
81+
state.value = next;
82+
}, () => {
83+
// A failed initial read leaves the prompt hidden until the next event.
84+
});
85+
});
86+
87+
onUnmounted(() => {
88+
removeListener?.();
89+
});
90+
</script>
91+
92+
<template>
93+
<div v-if="visible" class="update-toast" role="status" aria-live="polite">
94+
<div class="body">
95+
<div class="title">{{ title }}</div>
96+
<div class="msg">{{ t('update.prompt') }}</div>
97+
</div>
98+
<div class="acts">
99+
<button type="button" class="skip" @click="skip">{{ t('update.skip') }}</button>
100+
<button type="button" class="go" :disabled="busy" @click="void primary()">{{ primaryLabel }}</button>
101+
</div>
102+
</div>
103+
</template>
104+
105+
<style scoped>
106+
.update-toast {
107+
position: fixed;
108+
right: 16px;
109+
/* Sits above the WarningToasts stack (bottom: 84px) so the two never overlap. */
110+
bottom: 152px;
111+
z-index: 61;
112+
width: min(360px, calc(100vw - 32px));
113+
display: flex;
114+
flex-direction: column;
115+
gap: 10px;
116+
padding: 12px 13px;
117+
border: 1px solid var(--line);
118+
border-radius: 8px;
119+
background: var(--panel);
120+
box-shadow: 0 6px 22px rgba(0, 0, 0, 0.12);
121+
font-size: var(--ui-font-size);
122+
line-height: 1.45;
123+
}
124+
.title { color: var(--ink); font-weight: 600; overflow-wrap: anywhere; }
125+
.msg { margin-top: 2px; color: var(--muted); }
126+
.acts { display: flex; justify-content: flex-end; gap: 8px; }
127+
.skip,
128+
.go {
129+
padding: 5px 12px;
130+
border: 1px solid var(--line);
131+
border-radius: 8px;
132+
background: var(--bg);
133+
color: var(--muted);
134+
font: inherit;
135+
font-size: var(--ui-font-size-xs);
136+
cursor: pointer;
137+
}
138+
.skip:hover { color: var(--ink); }
139+
.go { border-color: transparent; background: var(--blue); color: #fff; font-weight: 600; }
140+
.go:disabled { opacity: 0.6; cursor: default; }
141+
142+
@media (max-width: 640px) {
143+
.update-toast {
144+
left: 12px;
145+
right: 12px;
146+
bottom: calc(150px + env(safe-area-inset-bottom));
147+
width: auto;
148+
}
149+
}
150+
</style>

apps/pythinker-web/src/components/settings/SettingsPane.vue

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type {
1010
} from '../../api/types';
1111
import type { ColorScheme, Theme } from '../../composables/usePythinkerWebClient';
1212
import type { SettingsTab } from '../../composables/useSettingsNav';
13+
import { useI18n } from 'vue-i18n';
1314
import AdvancedPage from './pages/AdvancedPage.vue';
1415
import AgentPage from './pages/AgentPage.vue';
1516
import ConnectorsPage from './pages/ConnectorsPage.vue';
@@ -53,11 +54,23 @@ const emit = defineEmits<{
5354
updateConfig: [patch: Partial<AppConfig>];
5455
restartConnector: [connectorId: string];
5556
setPluginEnabled: [payload: { pluginId: string; enabled: boolean }];
57+
close: [];
5658
}>();
59+
60+
const { t } = useI18n();
5761
</script>
5862

5963
<template>
6064
<main class="settings-pane con">
65+
<!-- Zero-height sticky strip: the close button rides the top-right corner
66+
without pushing the pages down or scrolling away with them. -->
67+
<div class="pane-top">
68+
<button type="button" class="close-btn" :title="t('settings.close')" :aria-label="t('settings.close')" @click="emit('close')">
69+
<svg viewBox="0 0 16 16" width="12" height="12" aria-hidden="true">
70+
<path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" />
71+
</svg>
72+
</button>
73+
</div>
6174
<GeneralPage
6275
v-show="activeTab === 'general'"
6376
:theme="theme"
@@ -116,4 +129,39 @@ const emit = defineEmits<{
116129
background: var(--bg);
117130
color: var(--ink);
118131
}
132+
.pane-top {
133+
position: sticky;
134+
top: 0;
135+
z-index: 2;
136+
display: flex;
137+
justify-content: flex-end;
138+
height: 0;
139+
}
140+
.close-btn {
141+
display: flex;
142+
align-items: center;
143+
justify-content: center;
144+
width: 28px;
145+
height: 28px;
146+
border: 1px solid color-mix(in srgb, var(--err) 35%, transparent);
147+
border-radius: 50%;
148+
background: color-mix(in srgb, var(--err) 10%, transparent);
149+
color: var(--err);
150+
cursor: pointer;
151+
}
152+
.close-btn:hover {
153+
color: #fff;
154+
background: var(--err);
155+
border-color: var(--err);
156+
}
157+
/* Windows draws its own min/max/close cluster in the top-right titlebar, so the
158+
pane control drops clear of it instead of stacking under the window buttons. */
159+
:global(html[data-desktop-platform='win32']) .pane-top {
160+
top: 8px;
161+
padding-right: 4px;
162+
}
163+
.close-btn:focus-visible {
164+
outline: 2px solid var(--blue);
165+
outline-offset: 1px;
166+
}
119167
</style>

apps/pythinker-web/src/i18n/locales/en/settings.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ export default {
22
title: 'Settings',
33
/** Sidebar control that leaves the settings route and restores the session list. */
44
backToSessions: 'Back to sessions',
5+
/** Round close control in the top-right corner of the settings pane. */
6+
close: 'Close settings',
57
groups: {
68
basics: 'Basics',
79
capabilities: 'Agent capabilities',
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export default {
2+
available: 'A new version is available',
3+
availableVersion: 'Version {version} is available',
4+
prompt: 'Install it now, or skip this version.',
5+
download: 'Download update',
6+
skip: 'Skip',
7+
} as const;

apps/pythinker-web/src/i18n/locales/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import en_onboarding from './en/onboarding';
2828
import en_settings from './en/settings';
2929
import en_header from './en/header';
3030
import en_sideChat from './en/sideChat';
31+
import en_update from './en/update';
3132

3233
export const messages = {
3334
en: {
@@ -61,6 +62,7 @@ export const messages = {
6162
settings: en_settings,
6263
header: en_header,
6364
sideChat: en_sideChat,
65+
update: en_update,
6466
},
6567
} as const;
6668

apps/pythinker-web/test/sidebar.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,12 @@ describe('Sidebar reference layout', () => {
168168
expect(wrapper.emitted('openSettings')).toHaveLength(1);
169169
});
170170

171+
it('starts a new session when the brand is clicked', async () => {
172+
const wrapper = mountSidebar();
173+
await wrapper.get('.ch .ch-brand').trigger('click');
174+
expect(wrapper.emitted('create')).toHaveLength(1);
175+
});
176+
171177
it('renders the New Session label without a workspace path row', () => {
172178
const wrapper = mountSidebar();
173179

0 commit comments

Comments
 (0)