-
Notifications
You must be signed in to change notification settings - Fork 6
feat(web): prompt for desktop updates and tidy the sidebar header #106
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@pymodel/pythinker-code": minor | ||
| --- | ||
|
|
||
| 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. |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -464,10 +464,15 @@ onBeforeUnmount(() => { | |||||||||||||||||||||||||
| <div class="col" :style="{ width: colWidth + 'px' }"> | ||||||||||||||||||||||||||
| <!-- Header: logo + collapse (no hard border — flows into workspace list) --> | ||||||||||||||||||||||||||
| <div class="ch"> | ||||||||||||||||||||||||||
| <div class="ch-brand"> | ||||||||||||||||||||||||||
| <PythinkerLogo size="sm" interactive /> | ||||||||||||||||||||||||||
| <button | ||||||||||||||||||||||||||
| type="button" | ||||||||||||||||||||||||||
| class="ch-brand" | ||||||||||||||||||||||||||
| :title="t('sidebar.newSession')" | ||||||||||||||||||||||||||
| @click.stop="emit('create')" | ||||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||
| <PythinkerLogo size="sm" /> | ||||||||||||||||||||||||||
| <span class="ch-name">Pythinker Code</span> | ||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||
| </button> | ||||||||||||||||||||||||||
| <button | ||||||||||||||||||||||||||
| type="button" | ||||||||||||||||||||||||||
| class="collapse-btn" | ||||||||||||||||||||||||||
|
|
@@ -805,6 +810,17 @@ onBeforeUnmount(() => { | |||||||||||||||||||||||||
| min-width: 0; | ||||||||||||||||||||||||||
| /* Take the row's slack so the action buttons group together on the right. */ | ||||||||||||||||||||||||||
| flex: 1; | ||||||||||||||||||||||||||
| padding: 0; | ||||||||||||||||||||||||||
| border: none; | ||||||||||||||||||||||||||
| background: none; | ||||||||||||||||||||||||||
| font: inherit; | ||||||||||||||||||||||||||
| text-align: left; | ||||||||||||||||||||||||||
| cursor: pointer; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| .ch-brand:focus-visible { | ||||||||||||||||||||||||||
| outline: 2px solid var(--blue); | ||||||||||||||||||||||||||
| outline-offset: 2px; | ||||||||||||||||||||||||||
| border-radius: 6px; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| .ch-name { | ||||||||||||||||||||||||||
| font-size: var(--ui-font-size); | ||||||||||||||||||||||||||
|
|
@@ -1198,11 +1214,35 @@ onBeforeUnmount(() => { | |||||||||||||||||||||||||
| text-align: left; | ||||||||||||||||||||||||||
| cursor: pointer; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| /* The sessions-mode gear sits on the trailing edge; the settings-mode back | ||||||||||||||||||||||||||
| arrow keeps the leading edge, where a back control belongs. */ | ||||||||||||||||||||||||||
| /* The sessions-mode gear centres in the sidebar and carries the New Session | ||||||||||||||||||||||||||
| card style; the settings-mode back arrow keeps the leading edge, where a | ||||||||||||||||||||||||||
| back control belongs. */ | ||||||||||||||||||||||||||
| .settings-row.end { | ||||||||||||||||||||||||||
| justify-content: flex-end; | ||||||||||||||||||||||||||
| text-align: right; | ||||||||||||||||||||||||||
| width: calc(100% - 24px); | ||||||||||||||||||||||||||
| margin: 0 12px 10px; | ||||||||||||||||||||||||||
| justify-content: center; | ||||||||||||||||||||||||||
| gap: 10px; | ||||||||||||||||||||||||||
| padding: 9px 10px; | ||||||||||||||||||||||||||
| border: 1px solid var(--line); | ||||||||||||||||||||||||||
| border-radius: 10px; | ||||||||||||||||||||||||||
| background: var(--soft); | ||||||||||||||||||||||||||
| color: var(--ink); | ||||||||||||||||||||||||||
| font-family: var(--mono); | ||||||||||||||||||||||||||
| line-height: 1; | ||||||||||||||||||||||||||
| text-align: center; | ||||||||||||||||||||||||||
| white-space: nowrap; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| .settings-row.end:hover { | ||||||||||||||||||||||||||
| background: color-mix(in srgb, var(--ink) 6%, var(--soft)); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| .settings-row.end:focus-visible { | ||||||||||||||||||||||||||
| outline-offset: 1px; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| /* The back control is secondary next to the settings nav above it, so it sits | ||||||||||||||||||||||||||
| a step dimmer than the muted default until hovered. */ | ||||||||||||||||||||||||||
| .settings-row:not(.end) { | ||||||||||||||||||||||||||
| color: color-mix(in srgb, var(--muted) 65%, transparent); | ||||||||||||||||||||||||||
| font-weight: 400; | ||||||||||||||||||||||||||
|
Comment on lines
+1241
to
+1245
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Restore the hover color for the settings-mode back control.
Proposed fix .settings-row:not(.end) {
color: color-mix(in srgb, var(--muted) 65%, transparent);
font-weight: 400;
}
+.settings-row:not(.end):hover {
+ color: var(--ink);
+}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| .settings-row:hover { color: var(--ink); background: var(--soft); } | ||||||||||||||||||||||||||
| .settings-row:focus-visible { | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,150 @@ | ||||||||||
| <!-- apps/pythinker-web/src/components/UpdateToast.vue --> | ||||||||||
| <!-- Desktop-only prompt: a new version is ready (or waiting to download). | ||||||||||
| Unlike WarningToasts this never auto-dismisses — it asks a question. --> | ||||||||||
| <script setup lang="ts"> | ||||||||||
| import { computed, onMounted, onUnmounted, ref } from 'vue'; | ||||||||||
| import { useI18n } from 'vue-i18n'; | ||||||||||
|
|
||||||||||
| const { t } = useI18n(); | ||||||||||
|
|
||||||||||
| const bridge = typeof window !== 'undefined' ? window.pythinkerDesktop : undefined; | ||||||||||
| const state = ref<DesktopUpdateState>(); | ||||||||||
| const busy = ref(false); | ||||||||||
| /** Versions the user skipped. Persisted per version so the prompt does not | ||||||||||
| return for the same build; an unnamed version is skipped for the session. */ | ||||||||||
| const skipped = ref<string[]>(readSkipped()); | ||||||||||
| const SKIP_KEY = 'pythinker.update.skipped'; | ||||||||||
|
Comment on lines
+15
to
+16
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Initialize Line 15 calls Proposed fix+const SKIP_KEY = 'pythinker.update.skipped';
const skipped = ref<string[]>(readSkipped());
-const SKIP_KEY = 'pythinker.update.skipped';Based on learnings: " 📝 Committable suggestion
Suggested change
🤖 Prompt for AI AgentsSource: Learnings |
||||||||||
|
|
||||||||||
| let removeListener: (() => void) | undefined; | ||||||||||
|
|
||||||||||
| function readSkipped(): string[] { | ||||||||||
| try { | ||||||||||
| const raw: unknown = JSON.parse(localStorage.getItem(SKIP_KEY) ?? '[]'); | ||||||||||
| return Array.isArray(raw) ? raw.filter((v): v is string => typeof v === 'string') : []; | ||||||||||
| } catch { | ||||||||||
| return []; | ||||||||||
| } | ||||||||||
| } | ||||||||||
|
|
||||||||||
| const visible = computed(() => { | ||||||||||
| const current = state.value; | ||||||||||
| if (current === undefined) return false; | ||||||||||
| if (current.status !== 'downloaded' && !(current.status === 'available' && !current.autoUpdate)) { | ||||||||||
| return false; | ||||||||||
| } | ||||||||||
| return !skipped.value.includes(current.version ?? ''); | ||||||||||
| }); | ||||||||||
|
|
||||||||||
| const title = computed(() => | ||||||||||
| state.value?.version | ||||||||||
| ? t('update.availableVersion', { version: state.value.version }) | ||||||||||
| : t('update.available'), | ||||||||||
| ); | ||||||||||
|
|
||||||||||
| const primaryLabel = computed(() => | ||||||||||
| state.value?.status === 'downloaded' ? t('settings.desktop.restartToUpdate') : t('update.download'), | ||||||||||
| ); | ||||||||||
|
|
||||||||||
| async function primary(): Promise<void> { | ||||||||||
| if (bridge === undefined || busy.value) return; | ||||||||||
| busy.value = true; | ||||||||||
| try { | ||||||||||
| state.value = | ||||||||||
| state.value?.status === 'downloaded' ? await bridge.quitAndInstall() : await bridge.checkForUpdates(); | ||||||||||
| } finally { | ||||||||||
| busy.value = false; | ||||||||||
| } | ||||||||||
| } | ||||||||||
|
|
||||||||||
| function skip(): void { | ||||||||||
| const next = [...skipped.value, state.value?.version ?? '']; | ||||||||||
| skipped.value = next; | ||||||||||
| try { | ||||||||||
| localStorage.setItem(SKIP_KEY, JSON.stringify(next.filter((v) => v !== ''))); | ||||||||||
| } catch { | ||||||||||
| // A blocked localStorage still skips for this session. | ||||||||||
| } | ||||||||||
| } | ||||||||||
|
|
||||||||||
| onMounted(() => { | ||||||||||
| // Dev-only preview: the real updater stays disabled outside packaged builds, | ||||||||||
| // so `?updateDemo=1` on the Vite dev server is the only way to see this. | ||||||||||
| if (import.meta.env.DEV && new URLSearchParams(window.location.search).has('updateDemo')) { | ||||||||||
| state.value = { status: 'downloaded', version: '0.0.0-dev', autoUpdate: true }; | ||||||||||
| return; | ||||||||||
| } | ||||||||||
| if (bridge === undefined) return; | ||||||||||
| removeListener = bridge.onUpdateState((next) => { | ||||||||||
| state.value = next; | ||||||||||
| }); | ||||||||||
| void bridge.getUpdateState().then((next) => { | ||||||||||
| state.value = next; | ||||||||||
| }, () => { | ||||||||||
| // A failed initial read leaves the prompt hidden until the next event. | ||||||||||
| }); | ||||||||||
| }); | ||||||||||
|
|
||||||||||
| onUnmounted(() => { | ||||||||||
| removeListener?.(); | ||||||||||
| }); | ||||||||||
| </script> | ||||||||||
|
|
||||||||||
| <template> | ||||||||||
| <div v-if="visible" class="update-toast" role="status" aria-live="polite"> | ||||||||||
| <div class="body"> | ||||||||||
| <div class="title">{{ title }}</div> | ||||||||||
| <div class="msg">{{ t('update.prompt') }}</div> | ||||||||||
| </div> | ||||||||||
| <div class="acts"> | ||||||||||
| <button type="button" class="skip" @click="skip">{{ t('update.skip') }}</button> | ||||||||||
| <button type="button" class="go" :disabled="busy" @click="void primary()">{{ primaryLabel }}</button> | ||||||||||
| </div> | ||||||||||
| </div> | ||||||||||
| </template> | ||||||||||
|
|
||||||||||
| <style scoped> | ||||||||||
| .update-toast { | ||||||||||
| position: fixed; | ||||||||||
| right: 16px; | ||||||||||
| /* Sits above the WarningToasts stack (bottom: 84px) so the two never overlap. */ | ||||||||||
| bottom: 152px; | ||||||||||
| z-index: 61; | ||||||||||
| width: min(360px, calc(100vw - 32px)); | ||||||||||
| display: flex; | ||||||||||
| flex-direction: column; | ||||||||||
| gap: 10px; | ||||||||||
| padding: 12px 13px; | ||||||||||
| border: 1px solid var(--line); | ||||||||||
| border-radius: 8px; | ||||||||||
| background: var(--panel); | ||||||||||
| box-shadow: 0 6px 22px rgba(0, 0, 0, 0.12); | ||||||||||
| font-size: var(--ui-font-size); | ||||||||||
| line-height: 1.45; | ||||||||||
| } | ||||||||||
| .title { color: var(--ink); font-weight: 600; overflow-wrap: anywhere; } | ||||||||||
| .msg { margin-top: 2px; color: var(--muted); } | ||||||||||
| .acts { display: flex; justify-content: flex-end; gap: 8px; } | ||||||||||
| .skip, | ||||||||||
| .go { | ||||||||||
| padding: 5px 12px; | ||||||||||
| border: 1px solid var(--line); | ||||||||||
| border-radius: 8px; | ||||||||||
| background: var(--bg); | ||||||||||
| color: var(--muted); | ||||||||||
| font: inherit; | ||||||||||
| font-size: var(--ui-font-size-xs); | ||||||||||
| cursor: pointer; | ||||||||||
| } | ||||||||||
| .skip:hover { color: var(--ink); } | ||||||||||
| .go { border-color: transparent; background: var(--blue); color: #fff; font-weight: 600; } | ||||||||||
| .go:disabled { opacity: 0.6; cursor: default; } | ||||||||||
|
|
||||||||||
| @media (max-width: 640px) { | ||||||||||
| .update-toast { | ||||||||||
| left: 12px; | ||||||||||
| right: 12px; | ||||||||||
| bottom: calc(150px + env(safe-area-inset-bottom)); | ||||||||||
| width: auto; | ||||||||||
| } | ||||||||||
| } | ||||||||||
| </style> | ||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| export default { | ||
| available: 'A new version is available', | ||
| availableVersion: 'Version {version} is available', | ||
| prompt: 'Install it now, or skip this version.', | ||
| download: 'Download update', | ||
| skip: 'Skip', | ||
| } as const; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Add an accessible name for the brand button.
The button's accessible name is currently
Pythinker Code, while its action is to create a new session. Thetitleattribute does not replace the button's content name. Add the localized action label.Proposed fix
<button type="button" class="ch-brand" :title="t('sidebar.newSession')" + :aria-label="t('sidebar.newSession')" `@click.stop`="emit('create')"📝 Committable suggestion
🤖 Prompt for AI Agents