Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
82 commits
Select commit Hold shift + click to select a range
9c52770
feat(desktop): a frame may load the preview's origin, the window not
MoSofi Oct 9, 2026
ce553b1
feat(server): startProject, a start that hands back close and busy
MoSofi Oct 9, 2026
5d32fd5
feat(server): a project the desktop app opens is served as on a terminal
MoSofi Oct 9, 2026
45812b3
feat(desktop): only the app's own server origin may call the bridge
MoSofi Oct 9, 2026
27826ea
feat(server): a host may refuse names from a project's .env and config
MoSofi Oct 9, 2026
2dd5937
feat(desktop): the server child serves a project folder through start…
MoSofi Oct 9, 2026
cb56a8f
feat(desktop): the server manager serves a project: port per fork, busy
MoSofi Oct 9, 2026
6e6df8e
feat(desktop): main opens a project folder: its own boot, quit asks f…
MoSofi Oct 9, 2026
49d3f4c
fix(server): startProject fills the environment in place, as a terminal
MoSofi Oct 9, 2026
6b5f1c7
test(e2e): a project folder in the desktop app, start to quit
MoSofi Oct 9, 2026
f546388
docs(desktop): say what the code does without naming plan tasks
MoSofi Oct 9, 2026
b82308b
fix(desktop): build the preview's origin from the parsed address
MoSofi Oct 9, 2026
91917d7
test(desktop): the project entries and the manager at their edges
MoSofi Oct 9, 2026
0975266
feat(server): one module says how a program is started, here or in th…
MoSofi Oct 9, 2026
056e658
feat(desktop): the app makes node, npm and npx stand-ins at every launch
MoSofi Oct 9, 2026
08d6c48
feat(desktop): the Mac build may load a project's own libraries
MoSofi Oct 9, 2026
a6bc9c3
feat(desktop): carry npm 10.9.8 as real files beside the archive
MoSofi Oct 9, 2026
70b04b4
fix(desktop): the build guard reaches a signed Mac app as an argument
MoSofi Oct 9, 2026
fcbcb74
fix(desktop): the .deb asks for libgbm and libasound
MoSofi Oct 9, 2026
78615ec
ci: one trial run, npm under the app's program on a real Linux
MoSofi Oct 9, 2026
b8b1ebd
ci: remove the trial run, it has answered
MoSofi Oct 9, 2026
8cdd18d
build(desktop): the archive holds only what each package ships
MoSofi Oct 9, 2026
6708928
feat(server): an install is marked finished; a hidden install command
MoSofi Oct 9, 2026
32f370c
feat(desktop): a new project installs from the lockfile the app carries
MoSofi Oct 9, 2026
fe46cb4
feat(desktop): the offline gate checks what a project may reach
MoSofi Oct 9, 2026
bc106e0
docs(desktop): the two offline promises
MoSofi Oct 9, 2026
29e03a6
docs(cli): the install command
MoSofi Oct 9, 2026
f649043
fix(cli): install is an ordinary command, listed like the rest
MoSofi Oct 9, 2026
7a0699f
chore(skills): regenerate the references after the CLI page
MoSofi Oct 9, 2026
5b50b5f
feat(desktop): find a git that works, or fetch one on a yes
MoSofi Oct 9, 2026
d45e6c0
feat(designer): versions that come on later keep what was built before
MoSofi Oct 9, 2026
e8aad36
feat(desktop): config.json version 2, the recent projects and where a…
MoSofi Oct 9, 2026
a351f66
chore(desktop): the packages the app's own pages are built with
MoSofi Oct 10, 2026
04808e8
feat(i18n): the desktop app's own words, in eight languages
MoSofi Oct 10, 2026
8748ed6
feat(desktop): the app opens on Start; a new project, the recent list
MoSofi Oct 10, 2026
57f4605
feat(desktop): close a project and come back to Start
MoSofi Oct 10, 2026
8343134
feat(designer): the project's button and Build | Share inside the des…
MoSofi Oct 10, 2026
f6743f2
chore(server): regenerate openapi for the desktop namespace
MoSofi Oct 10, 2026
4e16ec8
fix(desktop): the archive check reads every workspace package
MoSofi Oct 10, 2026
13418d0
fix(cli): start a whole-path program without the Windows shell
MoSofi Oct 10, 2026
757ec36
fix(desktop): take away a project folder that could not be finished
MoSofi Oct 10, 2026
bb0aed0
fix(desktop): the app's own menu from Start on; reload as shortcuts only
MoSofi Oct 10, 2026
45e4ffe
feat(ui): the rows mark replaces the hexagon; the app icon in forge o…
MoSofi Oct 10, 2026
ca21afc
feat(desktop): the starting screen draws the mark being laid, not a s…
MoSofi Oct 10, 2026
b37d9fb
feat(desktop): show the steps while a new app is being made
MoSofi Oct 10, 2026
b10388c
fix(designer): a screen that stops after it was looked at is looked a…
MoSofi Oct 10, 2026
3491e91
fix(desktop): the preview's new tab opens in the system's browser
MoSofi Oct 10, 2026
46e8490
feat(designer): a one-use link opens the dashboard in the system's br…
MoSofi Oct 10, 2026
5d55a83
feat(desktop): a recent project opens from its whole row, in the Desi…
MoSofi Oct 10, 2026
bd8a0f8
feat(desktop): offer to get the packages of a project that has none, …
MoSofi Oct 10, 2026
547a0da
feat(desktop): offer to keep versions where there is no git, and turn…
MoSofi Oct 10, 2026
9a8a1fb
fix(desktop): a recent project's path and day stay on one line
MoSofi Oct 10, 2026
44f6535
fix(designer): ask for the browser's sign-in link at the API's own ad…
MoSofi Oct 10, 2026
c2665be
feat(designer): say which names of a project's .env the app did not obey
MoSofi Oct 10, 2026
8438d6e
feat(desktop): the quit and close questions are asked in the page's l…
MoSofi Oct 10, 2026
4963d16
feat(desktop): a language or theme picked in the dashboard is kept fo…
MoSofi Oct 10, 2026
20daca5
test(desktop): the window manager's Start, its move between cookie ja…
MoSofi Oct 10, 2026
ae7baf1
feat(cli): folder-facts says what a project folder holds without runn…
MoSofi Oct 10, 2026
46ce6b0
feat(cli): install --update sets a project's Adminium to this version…
MoSofi Oct 10, 2026
d10c980
feat(desktop): an app's build lines are approved per computer, not by…
MoSofi Oct 10, 2026
dffa070
feat(desktop): opening a folder says what it holds, asks about a miss…
MoSofi Oct 10, 2026
1bb73d9
feat(desktop): an install that failed says which of four causes it was
MoSofi Oct 10, 2026
301cf44
feat(desktop): model keys are kept by the app and handed to a project…
MoSofi Oct 10, 2026
21236e2
feat(desktop): a project is built or shared: the steps before sharing…
MoSofi Oct 10, 2026
7a31d78
feat(desktop): export a project as one ZIP, with or without its data …
MoSofi Oct 10, 2026
3447870
fix(desktop): an export comes back to the page it was asked from
MoSofi Oct 10, 2026
f8bf9f1
feat(desktop): connect to another Adminium in a window of its own, wi…
MoSofi Oct 10, 2026
a288071
chore: merge main into plan-66-d
MoSofi Oct 10, 2026
e384db7
docs(desktop): say what the code does without naming documents a read…
MoSofi Oct 10, 2026
b8e8585
ci(desktop): a packed smoke, the entitlement and the pinned engine ch…
MoSofi Oct 10, 2026
ef41a66
fix(dashboard): the export dialog and the project button no longer im…
MoSofi Oct 10, 2026
4ff576c
fix(desktop): what a review of the whole of Part D found, in the open…
MoSofi Oct 10, 2026
b393bf4
fix(desktop): an export too large for one ZIP is refused, not saved b…
MoSofi Oct 10, 2026
5775b5b
fix(desktop): the starter lockfile reaches the packed app, and the pa…
MoSofi Oct 10, 2026
7fbf148
chore: merge main into plan-66-d
MoSofi Oct 10, 2026
541a943
test(desktop): a map for the test server's routes, and a fallback's e…
MoSofi Oct 10, 2026
3372d7e
feat(desktop): the app signs a project's owner in on this computer, w…
MoSofi Oct 10, 2026
d3017e5
Merge remote-tracking branch 'github/main' into plan-66-d
MoSofi Oct 10, 2026
f9ee8e8
feat(desktop): a shared project answers to its own addresses only, an…
MoSofi Oct 10, 2026
bf0b145
docs(desktop): the download page is where the app is installed from
MoSofi Oct 10, 2026
04b1b39
fix(desktop): hand a shared project's token over, answer bare names
MoSofi Oct 10, 2026
74e2471
test(desktop): the test server calls a route only when it is one
MoSofi Oct 10, 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
89 changes: 89 additions & 0 deletions .github/workflows/desktop-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,51 @@ jobs:
} >> "$GITHUB_ENV"
clang++-17 --version | head -1

# ── Step 3.6: the engine the app pins must be on npm ───────────────────────
#
# A new project made in the app installs `@adminiumjs/adminium` at the app's
# own version, from npm. An app released before that version is published
# makes projects that cannot be made. On a real tag its absence stops the
# release; on a manual run of an unreleased version it is said, and the
# starter lockfile and the packed smoke below (which makes a project) are skipped.
# Asked BEFORE packaging: the starter lockfile is made from it and packed.
- name: The engine the app pins is on npm
id: engine
shell: bash
run: |
set -euo pipefail
version="$(node -p "require('./apps/desktop/package.json').version")"
missing=""
for name in @adminiumjs/adminium @adminiumjs/public-client; do
if ! npm view "${name}@${version}" version >/dev/null 2>&1; then missing="${missing} ${name}@${version}"; fi
done
if [ -z "${missing}" ]; then
echo "published=true" >> "$GITHUB_OUTPUT"
echo "On npm: @adminiumjs/adminium@${version} and @adminiumjs/public-client@${version}."
else
echo "published=false" >> "$GITHUB_OUTPUT"
echo "::warning::Not on npm:${missing}. A project made in this build could not install its packages."
case "${GITHUB_REF}" in
refs/tags/desktop-v*) echo "::error::Publish the engine first: the desktop app is released only after the version it pins is on npm."; exit 1 ;;
esac
fi

# ── Step 3.6b: the starter lockfile, packed with the app ───────────────────
#
# What a new project installs is what this release was tried with, package
# by package: the lockfile is resolved here, once, and carried in the app
# (`resources/starter`, which is not in the repository). Without this step
# every packed app carries none and a new project resolves its ranges on
# the person's computer, on the day they click "Create".
- name: Make the starter lockfile
if: steps.engine.outputs.published == 'true'
shell: bash
run: |
set -euo pipefail
node scripts/release/starter-lockfile.mjs
test -s apps/desktop/resources/starter/starter-lock.json
test -s apps/desktop/resources/starter/starter.json

# ── Step 3: package + (mac) sign + notarize ──────────────────────────────
#
# The `dist` script (= `electron-builder`) is the ONLY sanctioned call site
Expand Down Expand Up @@ -542,6 +587,50 @@ jobs:
# rpm), the feed files, and .blockmaps into dist/. Ship the whole directory
# to the release job; retention is short because these are release-candidate
# binaries, not long-lived history (the published Release is the archive).
# ── Step 3.55: the entitlement a project's packages need, on the app AND its helper ──
#
# A project's own packages bring libraries signed by somebody else (SQLite,
# argon2, Tailwind's). The signed app loads them only with
# `disable-library-validation`, and the server runs in a HELPER, which has
# its own signature: the key on the app alone is a project that cannot start.
- name: The entitlement is on the app and on its helper (macOS)
if: matrix.platform == 'mac' && env.MAC_CSC_LINK != ''
shell: bash
run: |
set -euo pipefail
found=0
for app in apps/desktop/dist/mac*/Adminium.app; do
[ -d "$app" ] || continue
found=1
for signed in "$app" "$app"/Contents/Frameworks/*Helper*.app; do
[ -e "$signed" ] || continue
if ! codesign -d --entitlements - "$signed" 2>/dev/null | grep -q 'com.apple.security.cs.disable-library-validation'; then
echo "::error::$signed is signed without disable-library-validation: a project's packages would not load."
exit 1
fi
echo "ok: $signed"
done
done
[ "$found" = 1 ] || { echo "::error::no packed Adminium.app found to check"; exit 1; }

# ── Step 3.7: start the PACKED app and make a project in it ────────────────
#
# No other step runs what a person will run. This one starts the packed app
# (unpacked folder, the same files the installer lays down), makes a project
# (the engine's `new` run by the app's own program as Node, its packages
# fetched by the npm the app carries), runs one turn with a scripted model
# and checks the result. See scripts/release/desktop-packed-smoke.mjs.
- name: Packed smoke (make a project in the packed app)
if: steps.engine.outputs.published == 'true'
shell: bash
run: |
set -euo pipefail
if [ "${{ matrix.platform }}" = "linux" ]; then
xvfb-run -a node scripts/release/desktop-packed-smoke.mjs
else
node scripts/release/desktop-packed-smoke.mjs
fi

- name: Upload build artifacts
uses: actions/upload-artifact@v7
with:
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,6 @@ scripts/release/out/
# locks. Ignore the whole directory. Nothing here is shared today; add a negated
# `!` line if a specific config ever needs to be committed.
/.claude/

# Made at release (scripts/release/starter-lockfile.mjs): the lockfile a new project starts with.
apps/desktop/resources/starter/
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ React in `pages/` and `widgets/`.
the encryption key for every stored DSN and API key. Generate it once with
`openssl rand -hex 32`.

**Desktop app** — macOS, Windows and Linux, with no Docker, Node or terminal:
[adminium.dev/download](https://adminium.dev/download/#desktop)
([what each installer is](https://docs.adminium.dev/desktop/)).

**Docker Compose** — the reference deployment:

```sh
Expand Down
5 changes: 5 additions & 0 deletions apps/dashboard/src/app/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import {
type RouterHistory,
} from '@tanstack/react-router';
import { allLocales, getI18nRevision, subscribeI18nRevision } from '@adminium/i18n';

import { rememberInDesktopApp } from '../lib/desktop-runtime.js';
import { ThemeProvider, TooltipProvider, type ThemePrefs } from '@adminium/ui';
import { ChartDirectionBridge, WidgetRuntimeProvider } from '@adminium/widgets';

Expand Down Expand Up @@ -183,6 +185,9 @@ function RootComponent() {
{ globalDefaults: { locale: bootLocale() } }
: { userPrefs: toThemePrefs(boot.data.prefs) })}
onPrefChange={(key, value) => {
// In the desktop app the two the app's own screens share are kept by the app as well, so its first screen
// opens in the language and theme picked here. A pick only: nothing is written at boot.
rememberInDesktopApp(key, value);
// Persist per-user axes once signed in (ThemeProvider wiring).
if (!authed) return;
api.patch('/api/v1/me/prefs', { [key]: value }).catch(() => {
Expand Down
5 changes: 4 additions & 1 deletion apps/dashboard/src/designer/DesignBanners.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { Button, FormField, Input } from '@adminium/ui';
import { ApiError } from '../app/api.js';
import { designerApi, designerKeys, designerStateQuery, PREVIEW_USER_EMAIL } from './api.js';
import { t } from '../i18n/t.js';
import { getDesktopApi } from '../lib/desktop-runtime.js';
import { useDesignerMessages } from './designerMessages.js';
import { useAppToasts } from '../pages/toasts.js';

Expand Down Expand Up @@ -121,7 +122,9 @@ function OwnerPassword(): ReactNode {
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
<KeyRound aria-hidden="true" className="size-4 shrink-0 text-accent" />
<span className="min-w-0 flex-1">
{t('designer:banner.owner', 'You are this project’s owner, signed in by the link Adminium Designer printed. Set an address and a password before the project runs anywhere else.')}
{getDesktopApi() !== null
? t('designer:banner.ownerApp', 'You are this project’s owner, signed in by the Adminium app on this computer. Set an address and a password before the project is shared or runs anywhere else.')
: t('designer:banner.owner', 'You are this project’s owner, signed in by the link Adminium Designer printed. Set an address and a password before the project runs anywhere else.')}
</span>
{open ? null : (
<Button size="sm" onClick={() => setOpen(true)}>
Expand Down
5 changes: 3 additions & 2 deletions apps/dashboard/src/designer/SpentLinkPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
* shows commands elsewhere.
*/
import { useState, type ReactNode } from 'react';
import { Check, Copy, Hexagon, Link2Off } from 'lucide-react';
import { Check, Copy, Link2Off } from 'lucide-react';
import { AdminiumMark } from '@adminium/ui';

import { t } from '../i18n/t.js';
import { useDesignerMessages } from './designerMessages.js';
Expand All @@ -30,7 +31,7 @@ export function SpentLinkPage(): ReactNode {
<main className="flex min-h-dvh flex-col items-center justify-center gap-6 bg-bg p-6 text-fg">
<div className="flex items-center gap-2.5">
<span className="flex size-7 items-center justify-center rounded-lg bg-accent text-accent-fg" aria-hidden="true">
<Hexagon className="size-4" />
<AdminiumMark className="size-[18px]" />
</span>
<span className="text-[15px] font-extrabold tracking-tight">{t('designer:brand', 'Adminium Designer')}</span>
</div>
Expand Down
8 changes: 8 additions & 0 deletions apps/dashboard/src/designer/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ export interface DesignerState {
active: { sessionId: string; turn: number } | null;
/** The person asking is the owner `design` made, still with no password. */
ownerNeedsPassword?: boolean;
/** Names in the project's `.env` the app did not obey (it decides them itself). */
ignoredEnv?: string[];
}

/** Who the preview signs in as: the dashboard says so when it is opened as them outside the Designer's frame. */
Expand Down Expand Up @@ -88,6 +90,10 @@ export interface DesignerModels {
selected: { connectionId: string; model: string } | null;
verdicts: { connectionId: string; model: string; canBuild: boolean; message: string | null }[];
canAdd: boolean;
/** Inside the desktop app: where the app keeps keys (`null` and absent elsewhere: they are in the project's `.env`). */
kept?: 'key-store' | 'plain' | null;
/** Inside the desktop app: model names this project's `.env` sets, which are not used there. */
ignoredEnv?: string[];
}

export interface ConnectionDraft {
Expand Down Expand Up @@ -403,6 +409,8 @@ export const designerApi = {
/** One of the app's files a person may open, whole, with the hash it has now. */
fileContent: (id: string, path: string) => api.get<{ path: string; content: string; hash: string }>(`${BASE}/sessions/${id}/files/content?path=${encodeURIComponent(path)}`),
previewTicket: (id: string, to: string) => api.post<{ url: string; origin: string; seenAs?: string[] }>(`${BASE}/sessions/${id}/preview-ticket`, { to }),
/** A one-use link that signs this project's local owner in from the system's browser, landing on `to` (a path). */
browserLink: (to: string) => api.post<{ data: { url: string } }>('/api/v1/auth/design-link', { to }),
setOwnerPassword: (input: { email: string; password: string }) => api.post<{ email: string }>(`${BASE}/owner-password`, input),
createSession: (input: { appKey?: string; name?: string; /** The session's title until the Designer names the app. */ title?: string; /** A style picked at the start. */ style?: string; target: DesignerTarget; connectionId: string; model: string; text?: string; /** With `text`: this page will show the preview and say what it sees. */ sees?: boolean }) =>
api.post<{ session: DesignerSession; turn: number | null }>(`${BASE}/sessions`, input),
Expand Down
11 changes: 11 additions & 0 deletions apps/dashboard/src/designer/build/BuildPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,9 @@ import {
StoppedNote,
UsageLine,
} from '../parts/chat.js';
import { useDesktopVersions, VersionsOfferDialog } from '../parts/DesktopVersions.js';
import { TopBar } from '../parts/TopBar.js';
import { DesktopBuildShare } from '../parts/DesktopProject.js';
import { CHAT_WIDTH, FOLDED_NEED_GUESS, views } from './barLevel.js';
import { LeaveGuard } from './LeaveGuard.js';
import { StyleMenu } from './LookMenu.js';
Expand Down Expand Up @@ -91,6 +93,12 @@ export function BuildPage({ sessionId }: { sessionId: string }): ReactNode {
const session = useQuery(sessionQuery(sessionId));
const apps = useQuery({ ...yourAppsQuery(), enabled: session.data !== undefined });
const versions = useQuery({ ...versionsQuery(sessionId), enabled: session.data !== undefined });
// Inside the desktop app on a computer with no git: versions can be turned on without leaving the page.
const desktopVersions = useDesktopVersions(() => {
void queryClient.invalidateQueries({ queryKey: designerKeys.versions(sessionId) });
toasts.push({ variant: 'success', title: t('designer:versionsOffer.nowOn', 'Versions are on'), description: t('designer:versionsOffer.nowOnNext', 'Your next change is kept as a version.') });
});
const [offeringVersions, setOfferingVersions] = useState(false);
const { events, loaded } = useSessionEvents(sessionId);
const turns = useMemo(() => foldTurns(events), [events]);
const working = isWorking(turns);
Expand Down Expand Up @@ -498,6 +506,7 @@ export function BuildPage({ sessionId }: { sessionId: string }): ReactNode {

return (
<div className="flex h-dvh flex-col bg-bg text-fg">
{desktopVersions === null ? null : <VersionsOfferDialog versions={desktopVersions} open={offeringVersions} onOpenChange={setOfferingVersions} />}
<TopBar
build
middle={
Expand All @@ -510,6 +519,7 @@ export function BuildPage({ sessionId }: { sessionId: string }): ReactNode {
current={current}
busy={busy || working}
onGoBack={(version) => restore.mutate({ n: version.n, record: true })}
onTurnVersionsOn={desktopVersions === null || desktopVersions.state.on ? undefined : () => setOfferingVersions(true)}
/>
)
}
Expand All @@ -525,6 +535,7 @@ export function BuildPage({ sessionId }: { sessionId: string }): ReactNode {
onOpen={(id) => void navigate({ to: '/design/$sessionId', params: { sessionId: id } })}
/>
)}
<DesktopBuildShare />
<a
href="/"
aria-label={t('designer:build.openDash', 'Open Dashboard')}
Expand Down
5 changes: 3 additions & 2 deletions apps/dashboard/src/designer/build/MoreMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ export function MoreMenu({
seen: { label: string; tip: string } | null;
sees: boolean;
onSees: (on: boolean) => void;
onNewTab: () => void;
/** Absent where there is nowhere to open it. */
onNewTab?: (() => void) | undefined;
}): ReactNode {
const label = t('designer:preview.more', 'More');
return (
Expand Down Expand Up @@ -82,7 +83,7 @@ export function MoreMenu({
>
{t('designer:preview.sees', 'The Designer looks at the page after it builds')}
</DropdownMenuItem>
{rows ? (
{rows && onNewTab !== undefined ? (
<DropdownMenuItem icon={<ExternalLink className="rtl:-scale-x-100" />} onSelect={onNewTab} className={MENU_ROW}>
{t('designer:preview.newTab', 'Open in a new tab')}
</DropdownMenuItem>
Expand Down
4 changes: 3 additions & 1 deletion apps/dashboard/src/designer/build/Preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,9 @@ export function Preview({ preview, session, turns, onFix, compact }: { preview:
const sight = sightFrom(event.data, session.appKey);
if (sight !== null) {
// Once for each time the frame was opened, and never twice within two seconds: a screen that posts without end sends nothing more.
const due = sentSight.current.round !== round || Date.now() - sentSight.current.at > 2000;
// A sight that says the page stopped is passed on whenever it comes: it is the one the turn must not miss.
const stops = sight.faults.some((fault) => fault['kind'] === 'blank' || fault['kind'] === 'error');
const due = sentSight.current.round !== round || stops || Date.now() - sentSight.current.at > 2000;
if (runningNow.current && seesPage() && due && sentSight.current.count < 3) {
sentSight.current = { round, at: Date.now(), count: sentSight.current.round === round ? sentSight.current.count + 1 : 1 };
void designerApi.sendSight(session.id, sight).catch(() => undefined);
Expand Down
29 changes: 28 additions & 1 deletion apps/dashboard/src/designer/build/SessionTitle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
import { getI18nInstance, t } from '../../i18n/t.js';
import type { DesignerVersion } from '../api.js';
import { editedWhen } from '../home/YourApps.js';
import { VERSIONS_PILL } from '../parts/DesktopVersions.js';
import { MENU_HEADING } from './MoreMenu.js';
import { versionLabel, versionName } from './versionName.js';

Expand Down Expand Up @@ -63,6 +64,7 @@ export function SessionTitle({
current,
onGoBack,
busy,
onTurnVersionsOn,
}: {
title: string;
onRename: (title: string) => void;
Expand All @@ -71,6 +73,8 @@ export function SessionTitle({
current: DesignerVersion | null;
onGoBack: (version: DesignerVersion) => void;
busy: boolean;
/** Inside the desktop app, on a computer with no git: versions can be turned on from here. */
onTurnVersionsOn?: (() => void) | undefined;
}): ReactNode {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(title);
Expand Down Expand Up @@ -130,7 +134,30 @@ export function SessionTitle({
</Tooltip>
)}

{!available ? (
{!available && onTurnVersionsOn !== undefined ? (
<DropdownMenu modal={false}>
<DropdownMenuTrigger
aria-label={t('designer:versionsOffer.offButton', 'Versions: off')}
className="inline-flex h-8 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-[9px] border border-border bg-surface-2 pe-[7px] ps-[9px] text-[12.5px] font-bold leading-[normal] text-fg hover:border-border-strong focus-visible:outline-2 focus-visible:outline-accent"
>
<History aria-hidden="true" className="size-3.5 text-fg-subtle" />
<span className="text-fg-muted">{t('designer:versionsOffer.offWord', 'Off')}</span>
<ChevronDown aria-hidden="true" className="size-3.5 text-fg-subtle" />
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="w-[420px] max-w-[calc(100vw-24px)] rounded-[14px] p-1.5 leading-[normal]">
<DropdownMenuLabel className={MENU_HEADING}>{t('designer:versions.title', 'Versions')}</DropdownMenuLabel>
<div className="flex flex-col items-center gap-3 px-4 pb-5 pt-[22px] text-center">
<span aria-hidden="true" className="flex size-10 items-center justify-center rounded-[12px] bg-surface-3 text-fg-muted">
<History className="size-[19px]" />
</span>
<span className="text-[13px] text-fg-muted">{t('designer:versionsOffer.offHere', 'Versions are off on this computer.')}</span>
<DropdownMenuItem onSelect={onTurnVersionsOn} icon={<History />} className={`${VERSIONS_PILL} w-auto [&_svg]:size-3.5`}>
{t('designer:versionsOffer.turnOn', 'Turn versions on')}
</DropdownMenuItem>
</div>
</DropdownMenuContent>
</DropdownMenu>
) : !available ? (
<Tooltip content={t('designer:versions.offHint', 'Versions need git on this machine. Install git and start the Designer again.')}>
<span tabIndex={0} className="inline-flex items-center gap-1.5 rounded-lg px-2 py-1 text-xs font-semibold text-fg-subtle">
<History aria-hidden="true" className="size-3.5" />
Expand Down
Loading
Loading