diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml deleted file mode 100644 index 843a7b925..000000000 --- a/.github/workflows/pages.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: Pages - -on: - push: - branches: [main] - paths: - - ".github/workflows/pages.yml" - - "site/**" - workflow_dispatch: - -concurrency: - group: pages - cancel-in-progress: true - -jobs: - build: - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - uses: actions/checkout@v6 - - uses: actions/configure-pages@v5 - - uses: actions/upload-pages-artifact@v4 - with: - path: site - - deploy: - needs: build - runs-on: ubuntu-latest - permissions: - pages: write - id-token: write - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - steps: - - id: deployment - uses: actions/deploy-pages@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f0d7d9720..edace9e7e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -147,6 +147,9 @@ jobs: APPLE_API_KEY_BASE64: ${{ secrets.APPLE_API_KEY_BASE64 }} APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} CSC_LINK: ${{ secrets.MACOS_CERTIFICATE_P12 }} CSC_KEY_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} run: >- diff --git a/README.md b/README.md index c6c28b248..70b0faa48 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,18 @@ It is built from two modules that share one controller API: Electron desktop shell. Hosts the Workbench (`/agent`), consolidated Configure surface, settings, usage, logs, and browser-facing API routes. +## Mobile companion + +[KittyLitter](https://kittylitter.app) connects to Local Studio so the same +agent sessions, streaming content, reasoning, tool calls, and tool results are +available on iPhone, iPad, and Android. Pair from **Settings → Profile & phone → +Connect your phone**. The QR code and copied connection JSON are private +controller credentials; share them only with a device you trust. + +See the complete pairing, version, and security guide at +[localstudio.ai/mobile](https://localstudio.ai/mobile). Mobile pairing requires +Local Studio 2.9.0 or newer and KittyLitter 1.6.0 or newer. + ## What is a controller? A controller is the backend process the UI talks to — the Bun/Hono diff --git a/frontend/src/app/styles/globals/base.css b/frontend/src/app/styles/globals/base.css index 05c99543f..0a8db54d3 100644 --- a/frontend/src/app/styles/globals/base.css +++ b/frontend/src/app/styles/globals/base.css @@ -172,7 +172,9 @@ a.nav-link:hover, the pointer is over the sidebar. The 6px gutter is stable so rows do not shift when the thumb appears. */ .sidebar-scroller { - scrollbar-width: none; + scrollbar-gutter: stable; + scrollbar-width: thin; + scrollbar-color: transparent transparent; } .sidebar-scroller::-webkit-scrollbar { width: 6px; @@ -185,7 +187,6 @@ a.nav-link:hover, background: transparent; } .sidebar-scroller:hover { - scrollbar-width: thin; scrollbar-color: color-mix(in srgb, var(--fg) 22%, transparent) transparent; } .sidebar-scroller:hover::-webkit-scrollbar-thumb { diff --git a/frontend/src/features/agent/messages/helpers.ts b/frontend/src/features/agent/messages/helpers.ts index 66bcd049e..c344c7128 100644 --- a/frontend/src/features/agent/messages/helpers.ts +++ b/frontend/src/features/agent/messages/helpers.ts @@ -1,5 +1,9 @@ import { piEventIsSuccessfulCompaction } from "@shared/agent/pi-events"; -import { cleanSessionTitle, isPlaceholderSessionTitle } from "@shared/agent/session-title"; +import { + cleanSessionTitle, + isPlaceholderSessionTitle, + sessionTitleFromUserPrompt, +} from "@shared/agent/session-title"; export { cleanSessionTitle, isPlaceholderSessionTitle }; import type { @@ -107,7 +111,7 @@ export function formatTokenCount(tokens: number): string { } export function sessionTitleFromPrompt(text: string): string { - return cleanSessionTitle(text.replace(/\s+/g, " ").trim().slice(0, 48)) || "New session"; + return cleanSessionTitle(sessionTitleFromUserPrompt(text).slice(0, 48)) || "New session"; } export function visibleUserTextFromPi(text: string): string { diff --git a/frontend/src/features/agent/messages/session-title.test.ts b/frontend/src/features/agent/messages/session-title.test.ts new file mode 100644 index 000000000..67bc4388f --- /dev/null +++ b/frontend/src/features/agent/messages/session-title.test.ts @@ -0,0 +1,21 @@ +import assert from "node:assert/strict"; +import { describe, test } from "node:test"; +import { sessionTitleFromPrompt } from "./helpers"; + +describe("sessionTitleFromPrompt", () => { + test("derives a title after the internal browser context envelope", () => { + assert.equal( + sessionTitleFromPrompt( + "\nA server-side browser is available.\n\n\nReview the release status", + ), + "Review the release status", + ); + }); + + test("preserves legitimate user text containing the context tag name", () => { + assert.equal( + sessionTitleFromPrompt("Explain in this XML document"), + "Explain in this XML document", + ); + }); +}); diff --git a/frontend/src/features/agent/runtime/api.ts b/frontend/src/features/agent/runtime/api.ts index 1119fa5f7..d23486f72 100644 --- a/frontend/src/features/agent/runtime/api.ts +++ b/frontend/src/features/agent/runtime/api.ts @@ -50,6 +50,33 @@ const safeJsonEffect = (response: Response): Effect.Effect => catch: (error) => error, }); +const AbortSessionResponseSchema = Schema.Struct({ + ok: Schema.Boolean, + cleared: Schema.Struct({ + steering: Schema.Array(Schema.String), + followUp: Schema.Array(Schema.String), + }), +}); + +const decodeAbortSessionResponse = Schema.decodeUnknownOption(AbortSessionResponseSchema, { + onExcessProperty: "preserve", +}); + +export type AbortSessionResult = { + steering: string[]; + followUp: string[]; +}; + +export function parseAbortSessionResult(input: unknown): AbortSessionResult { + const decoded = decodeAbortSessionResponse(input); + return decoded._tag === "Some" + ? { + steering: [...decoded.value.cleared.steering], + followUp: [...decoded.value.cleared.followUp], + } + : { steering: [], followUp: [] }; +} + export function listRuntimeSessions(): Promise { return Effect.runPromise( Effect.gen(function* () { @@ -79,16 +106,17 @@ export function loadRuntimeStatus( ); } -export function abortSession(sessionId: string): Promise { +export function abortSession(sessionId: string): Promise { return Effect.runPromise( - fetchEffect("/api/agent/abort", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ sessionId }), - }).pipe( - Effect.map(() => undefined), - Effect.catch(() => Effect.succeed(undefined)), - ), + Effect.gen(function* () { + const response = yield* fetchEffect("/api/agent/abort", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessionId }), + }); + const payload = yield* safeJsonEffect(response); + return parseAbortSessionResult(payload); + }).pipe(Effect.catch(() => Effect.succeed({ steering: [], followUp: [] }))), ); } diff --git a/frontend/src/features/agent/runtime/engine.ts b/frontend/src/features/agent/runtime/engine.ts index 9f8b749f1..a538970d0 100644 --- a/frontend/src/features/agent/runtime/engine.ts +++ b/frontend/src/features/agent/runtime/engine.ts @@ -63,7 +63,7 @@ export type SessionEngine = { runtime: string, piSessionId?: string | null, ) => Promise; - abortTurn: (sessionId: SessionId) => Promise; + abortTurn: (sessionId: SessionId) => Promise; loadAndReplay: (piSessionId: string, sessionId: SessionId) => Promise; /** Fetch and prepend the previous page of older history (tail paging). */ loadEarlier: (sessionId: SessionId) => Promise; @@ -217,7 +217,7 @@ export function useSessionEngine(deps: UseSessionEngineDeps): SessionEngine { // and /abort has no piSessionId fallback lookup. const runtime = sessionRuntimeController().connectionKey(sessionId); updateSession(sessionId, (session) => ({ ...session, status: "stopping" })); - yield* Effect.tryPromise({ + const cleared = yield* Effect.tryPromise({ try: () => api.abortSession(runtime), catch: (error) => error, }); @@ -229,6 +229,7 @@ export function useSessionEngine(deps: UseSessionEngineDeps): SessionEngine { // last streamed text is committed before we finalize. sessionRuntimeController().flush(sessionId); updateSession(sessionId, settleTurnFinalizingTools); + return cleared; }), ), [updateSession], @@ -440,15 +441,7 @@ export function useSessionEngine(deps: UseSessionEngineDeps): SessionEngine { ), ), ), - [ - browserToolEnabled, - browserBackend, - cwd, - loadAndReplay, - modelId, - thinkingLevel, - updateSession, - ], + [browserToolEnabled, browserBackend, cwd, loadAndReplay, modelId, thinkingLevel, updateSession], ); const acceptsControl = useCallback( diff --git a/frontend/src/features/agent/ui/chat-pane-composer.ts b/frontend/src/features/agent/ui/chat-pane-composer.ts index 7d4698594..ccc813ad1 100644 --- a/frontend/src/features/agent/ui/chat-pane-composer.ts +++ b/frontend/src/features/agent/ui/chat-pane-composer.ts @@ -2,6 +2,7 @@ import { useCallback, useMemo, + useRef, type ChangeEvent, type ClipboardEvent, type Dispatch, @@ -29,6 +30,11 @@ import { imageFileFromDataUrlText, } from "@/features/agent/ui/chat-attachments"; import { useMountSubscription } from "@/hooks/use-mount-subscription"; +import { + recentComposerHistory, + stepComposerHistory, + type ComposerHistoryCursor, +} from "@/features/agent/ui/composer-history"; export type UpdateTab = (tabId: string, patch: (tab: SessionTab) => SessionTab) => void; @@ -162,6 +168,11 @@ export function useComposerTextareaBehavior({ abortTurn: () => Promise; attachFiles: (files: FileList | File[] | null) => Promise; }) { + const historyNavigationRef = useRef<{ + sessionId: string; + cursor: ComposerHistoryCursor; + }>({ sessionId: "", cursor: { index: -1, draft: "" } }); + const resizeAfterCommit = useCallback( (nextValue: string, nextCaret: number) => { requestAnimationFrame(() => { @@ -213,6 +224,10 @@ export function useComposerTextareaBehavior({ (event: ChangeEvent) => { const value = event.target.value; if (!activeTab) return; + historyNavigationRef.current = { + sessionId: activeTab.id, + cursor: { index: -1, draft: value }, + }; updateTab(activeTab.id, (tab) => ({ ...tab, input: value })); setMention(value ? detectComposerMention(value, event.currentTarget.selectionStart) : null); const element = event.currentTarget; @@ -267,9 +282,49 @@ export function useComposerTextareaBehavior({ [mentionIndex, mentionRows, selectMentionRow, setMention, setMentionIndex], ); + const handleComposerHistoryKey = useCallback( + (event: KeyboardEvent): boolean => { + if ( + !activeTab || + (event.key !== "ArrowUp" && event.key !== "ArrowDown") || + event.altKey || + event.ctrlKey || + event.metaKey || + event.nativeEvent.isComposing + ) { + return false; + } + const history = recentComposerHistory(activeTab.messages); + const stored = historyNavigationRef.current; + const expectedValue = + stored.sessionId === activeTab.id && stored.cursor.index >= 0 + ? history[stored.cursor.index] + : stored.cursor.draft; + const cursor = + stored.sessionId === activeTab.id && expectedValue === activeTab.input + ? stored.cursor + : { index: -1, draft: activeTab.input }; + if (cursor.index < 0 && activeTab.input.length > 0) return false; + const step = stepComposerHistory( + activeTab.messages, + cursor, + event.key === "ArrowUp" ? "older" : "newer", + ); + if (!step) return false; + event.preventDefault(); + historyNavigationRef.current = { sessionId: activeTab.id, cursor: step.cursor }; + updateTab(activeTab.id, (tab) => ({ ...tab, input: step.value })); + setMention(null); + resizeAfterCommit(step.value, step.value.length); + return true; + }, + [activeTab, resizeAfterCommit, setMention, updateTab], + ); + const handleComposerKeyDown = useCallback( (event: KeyboardEvent) => { if (mention && handleMentionKey(event)) return; + if (handleComposerHistoryKey(event)) return; // While a turn is running, Enter QUEUES rather than steers. Steering // interrupts the agent's plan mid-flight, so it stays a deliberate act — // the drawer's "Interrupt now" button, promoting an item in the queue @@ -292,7 +347,15 @@ export function useComposerTextareaBehavior({ } } }, - [abortTurn, activeTab, handleMentionKey, mention, queueMessage, running], + [ + abortTurn, + activeTab, + handleComposerHistoryKey, + handleMentionKey, + mention, + queueMessage, + running, + ], ); return { diff --git a/frontend/src/features/agent/ui/chat-pane-send-flow-model.test.ts b/frontend/src/features/agent/ui/chat-pane-send-flow-model.test.ts new file mode 100644 index 000000000..a9fa0aab0 --- /dev/null +++ b/frontend/src/features/agent/ui/chat-pane-send-flow-model.test.ts @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + messagesToResumeAfterAbort, + removePendingSteersClearedByAbort, +} from "./chat-pane-send-flow-model"; + +test("stop resumes the visible queue without duplicating the runtime copy", () => { + assert.deepEqual( + messagesToResumeAfterAbort( + [{ id: "queue-1", mode: "follow_up", text: "send this next", sent: true }], + { steering: [], followUp: ["send this next"] }, + ), + ["send this next"], + ); +}); + +test("stop recovers runtime-only steering and follow-ups in delivery order", () => { + assert.deepEqual( + messagesToResumeAfterAbort([], { + steering: ["steer now"], + followUp: ["\ninternal\n\n\nfollow up after stop"], + }), + ["steer now", "follow up after stop"], + ); +}); + +test("stop replaces an undelivered optimistic steer instead of duplicating it", () => { + assert.deepEqual( + removePendingSteersClearedByAbort( + [ + { id: "user-1", role: "user", text: "already delivered" }, + { + id: "user-2", + role: "user", + text: "steer now", + pending: true, + awaitingEcho: true, + }, + ], + { steering: ["steer now"], followUp: [] }, + ).map((message) => message.id), + ["user-1"], + ); +}); diff --git a/frontend/src/features/agent/ui/chat-pane-send-flow-model.ts b/frontend/src/features/agent/ui/chat-pane-send-flow-model.ts new file mode 100644 index 000000000..684629e89 --- /dev/null +++ b/frontend/src/features/agent/ui/chat-pane-send-flow-model.ts @@ -0,0 +1,59 @@ +import type { AbortSessionResult } from "@/features/agent/runtime/api"; +import { + visibleUserTextFromPi, + type ChatMessage, + type QueuedMessage, +} from "@/features/agent/messages"; + +function visibleText(text: string): string { + return visibleUserTextFromPi(text).trim() || text.trim(); +} + +function unmatchedRuntimeFollowUps(local: string[], runtime: string[]): string[] { + const pending = new Map(); + for (const text of local) pending.set(text, (pending.get(text) ?? 0) + 1); + return runtime.flatMap((text) => { + const normalized = visibleText(text); + const count = pending.get(normalized) ?? 0; + if (count > 0) { + pending.set(normalized, count - 1); + return []; + } + return normalized ? [normalized] : []; + }); +} + +export function messagesToResumeAfterAbort( + queue: QueuedMessage[], + cleared: AbortSessionResult, +): string[] { + const steering = cleared.steering.map(visibleText).filter(Boolean); + const localFollowUps = queue + .filter((item) => item.mode === "follow_up") + .map((item) => item.text.trim()) + .filter(Boolean); + const runtimeFollowUps = cleared.followUp.map(visibleText).filter(Boolean); + return [ + ...steering, + ...localFollowUps, + ...unmatchedRuntimeFollowUps(localFollowUps, runtimeFollowUps), + ]; +} + +export function removePendingSteersClearedByAbort( + messages: ChatMessage[], + cleared: AbortSessionResult, +): ChatMessage[] { + const pending = new Map(); + for (const text of cleared.steering.map(visibleText).filter(Boolean)) { + pending.set(text, (pending.get(text) ?? 0) + 1); + } + return messages.filter((message) => { + if (message.role !== "user" || !message.awaitingEcho) return true; + const text = visibleText(message.text); + const count = pending.get(text) ?? 0; + if (count === 0) return true; + pending.set(text, count - 1); + return false; + }); +} diff --git a/frontend/src/features/agent/ui/chat-pane-send-flow.ts b/frontend/src/features/agent/ui/chat-pane-send-flow.ts index 4cf77be4a..28fe3b06c 100644 --- a/frontend/src/features/agent/ui/chat-pane-send-flow.ts +++ b/frontend/src/features/agent/ui/chat-pane-send-flow.ts @@ -21,6 +21,10 @@ import { imageInputsFromAttachments, type ChatAttachment, } from "@/features/agent/ui/chat-attachments"; +import { + messagesToResumeAfterAbort, + removePendingSteersClearedByAbort, +} from "@/features/agent/ui/chat-pane-send-flow-model"; type UseChatPaneSendFlowOptions = { activeTab: SessionTab | null; @@ -59,6 +63,7 @@ export function useChatPaneSendFlow({ }: UseChatPaneSendFlowOptions) { const composerSubmitInFlightRef = useRef(new Set()); const controlSubmitInFlightRef = useRef(new Set()); + const abortSubmitInFlightRef = useRef(new Set()); const buildPromptArgs = useCallback( (sessionId: string, rawText: string, effectiveBrowserEnabled = browserToolEnabled) => { @@ -173,13 +178,13 @@ export function useChatPaneSendFlow({ ? [ ...t.messages, { - id: pendingSteerId, - role: "user", - text, - pending: true, - awaitingEcho: true, - timestamp: nowLabel(), - }, + id: pendingSteerId, + role: "user", + text, + pending: true, + awaitingEcho: true, + timestamp: nowLabel(), + }, ] : t.messages, })); @@ -379,8 +384,24 @@ export function useChatPaneSendFlow({ const abortTurn = useCallback(() => { if (!activeTab) return Promise.resolve(); - return engine.abortTurn(activeTab.id); - }, [activeTab, engine]); + const tab = activeTab; + return runGuardedSubmit(abortSubmitInFlightRef.current, tab.id, async () => { + const cleared = await engine.abortTurn(tab.id); + const pending = messagesToResumeAfterAbort(tab.queue ?? [], cleared); + if (pending.length === 0) return; + updateTab(tab.id, (current) => ({ + ...current, + queue: [], + messages: removePendingSteersClearedByAbort(current.messages, cleared), + })); + const [next, ...remaining] = pending; + if (!next) return; + await submitPrompt(next, tab.id); + for (const text of remaining) { + await queueAndSendControl("follow_up", text, tab, tab.id, cwd); + } + }); + }, [activeTab, cwd, engine, queueAndSendControl, runGuardedSubmit, submitPrompt, updateTab]); // Re-run the last user turn after a failure (a 503, a network blip). On a // *send* failure the text is restored to the composer, but a turn that errors diff --git a/frontend/src/features/agent/ui/composer-history.test.ts b/frontend/src/features/agent/ui/composer-history.test.ts new file mode 100644 index 000000000..4260d7a78 --- /dev/null +++ b/frontend/src/features/agent/ui/composer-history.test.ts @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import type { ChatMessage } from "@/features/agent/messages"; +import { stepComposerHistory, type ComposerHistoryCursor } from "./composer-history"; + +function user(id: number): ChatMessage { + return { id: `user-${id}`, role: "user", text: `message ${id}` }; +} + +test("composer history navigates the five latest sent messages and restores the draft", () => { + const messages: ChatMessage[] = [ + user(1), + { id: "assistant-1", role: "assistant", text: "reply" }, + user(2), + user(3), + user(4), + user(5), + user(6), + ]; + let cursor: ComposerHistoryCursor = { index: -1, draft: "unfinished draft" }; + const older: string[] = []; + for (let index = 0; index < 6; index += 1) { + const step = stepComposerHistory(messages, cursor, "older"); + assert.ok(step); + cursor = step.cursor; + older.push(step.value); + } + assert.deepEqual(older, [ + "message 6", + "message 5", + "message 4", + "message 3", + "message 2", + "message 2", + ]); + + const newer: string[] = []; + for (let index = 0; index < 5; index += 1) { + const step = stepComposerHistory(messages, cursor, "newer"); + assert.ok(step); + cursor = step.cursor; + newer.push(step.value); + } + assert.deepEqual(newer, ["message 3", "message 4", "message 5", "message 6", "unfinished draft"]); +}); diff --git a/frontend/src/features/agent/ui/composer-history.ts b/frontend/src/features/agent/ui/composer-history.ts new file mode 100644 index 000000000..d1459a5c9 --- /dev/null +++ b/frontend/src/features/agent/ui/composer-history.ts @@ -0,0 +1,40 @@ +import type { ChatMessage } from "@/features/agent/messages"; + +const COMPOSER_HISTORY_LIMIT = 5; + +export type ComposerHistoryCursor = { + index: number; + draft: string; +}; + +export type ComposerHistoryStep = { + cursor: ComposerHistoryCursor; + value: string; +}; + +export function recentComposerHistory(messages: ChatMessage[]): string[] { + return messages + .filter((message) => message.role === "user" && message.text.trim()) + .slice(-COMPOSER_HISTORY_LIMIT) + .reverse() + .map((message) => message.text.trim()); +} + +export function stepComposerHistory( + messages: ChatMessage[], + cursor: ComposerHistoryCursor, + direction: "older" | "newer", +): ComposerHistoryStep | null { + const history = recentComposerHistory(messages); + if (direction === "older") { + if (history.length === 0) return null; + const index = Math.min(cursor.index + 1, history.length - 1); + return { cursor: { ...cursor, index }, value: history[index] ?? cursor.draft }; + } + if (cursor.index < 0) return null; + const index = cursor.index - 1; + return { + cursor: { ...cursor, index }, + value: index < 0 ? cursor.draft : (history[index] ?? cursor.draft), + }; +} diff --git a/frontend/src/features/agent/ui/projects-nav/helpers.test.ts b/frontend/src/features/agent/ui/projects-nav/helpers.test.ts new file mode 100644 index 000000000..76240647c --- /dev/null +++ b/frontend/src/features/agent/ui/projects-nav/helpers.test.ts @@ -0,0 +1,13 @@ +import assert from "node:assert/strict"; +import { describe, test } from "node:test"; +import { visibleSessionAge } from "./helpers"; + +describe("visibleSessionAge", () => { + test("hides timestamps while a session is running", () => { + assert.equal(visibleSessionAge(true, "2026-07-31T12:00:00.000Z"), ""); + }); + + test("preserves timestamps for inactive sessions", () => { + assert.equal(visibleSessionAge(false, new Date().toISOString()), "now"); + }); +}); diff --git a/frontend/src/features/agent/ui/projects-nav/helpers.ts b/frontend/src/features/agent/ui/projects-nav/helpers.ts index d3a6c20ed..77dc0cb84 100644 --- a/frontend/src/features/agent/ui/projects-nav/helpers.ts +++ b/frontend/src/features/agent/ui/projects-nav/helpers.ts @@ -89,6 +89,10 @@ export function relativeAge(value?: string | null): string { return `${Math.floor(days / 7)}w`; } +export function visibleSessionAge(isRunning: boolean, value?: string | null): string { + return isRunning ? "" : relativeAge(value); +} + export function hrefWithOpenNonce(href: string): string { const separator = href.includes("?") ? "&" : "?"; return `${href}${separator}open=${nextNavigationIntent()}`; diff --git a/frontend/src/features/agent/ui/projects-nav/session-nav-row.tsx b/frontend/src/features/agent/ui/projects-nav/session-nav-row.tsx index f99cc0269..d3cd00d96 100644 --- a/frontend/src/features/agent/ui/projects-nav/session-nav-row.tsx +++ b/frontend/src/features/agent/ui/projects-nav/session-nav-row.tsx @@ -8,7 +8,7 @@ import { useRef, useState, type DragEvent, type MouseEvent } from "react"; import { useClickOutside } from "@/features/agent/hooks/use-click-outside"; import { Archive, MoreIcon, PinIcon, PinOffIcon, SquarePen, X } from "@/ui/icon-registry"; import type { SessionPref } from "@/features/agent/messages/prefs"; -import { hrefWithOpenNonce, navigateToSessionHref, relativeAge } from "./helpers"; +import { hrefWithOpenNonce, navigateToSessionHref, visibleSessionAge } from "./helpers"; import { PinButton } from "./nav-chrome"; const SESSION_MENU_CLASS = `absolute right-0 top-6 isolate z-[999] min-w-[180px] ${POPOVER_MENU_CLASS}`; @@ -317,14 +317,16 @@ function SessionRowContent({ timestamp?: string | null; label: string; }) { - const age = relativeAge(timestamp); + const age = visibleSessionAge(isRunning, timestamp); return ( <> {label} {isRunning ? ( - + + + ) : finished ? ( { test("keeps automations in the primary workspace navigation, sessions live in search", () => { @@ -35,4 +39,14 @@ describe("left sidebar navigation", () => { assert.match(desktopSidebar, /ChevronLeft className="h-3 w-3"/); assert.match(desktopSidebar, /ChevronRight className="h-3 w-3"/); }); + + test("reserves the scrollbar gutter while only the thumb visibility changes", () => { + const resting = baseStyles.match(/\.sidebar-scroller \{([\s\S]*?)\}/)?.[1] ?? ""; + const hovered = baseStyles.match(/\.sidebar-scroller:hover \{([\s\S]*?)\}/)?.[1] ?? ""; + + assert.match(resting, /scrollbar-gutter:\s*stable/); + assert.match(resting, /scrollbar-width:\s*thin/); + assert.match(resting, /scrollbar-color:\s*transparent transparent/); + assert.doesNotMatch(hovered, /scrollbar-width/); + }); }); diff --git a/package.json b/package.json index dbc956359..866eeea72 100644 --- a/package.json +++ b/package.json @@ -14,9 +14,10 @@ "start": "npm --prefix frontend start", "test": "npm --prefix frontend test && npm --prefix services/agent-runtime test", "test:e2e": "npm --prefix frontend run test:e2e", - "check": "npm run check:contracts && npm run check:structure && npm run check:frontend && npm run check:controller && npm run check:agent-runtime", + "check": "npm run check:contracts && npm run check:structure && npm run check:release && npm run check:frontend && npm run check:controller && npm run check:agent-runtime", "check:contracts": "node scripts/validate-shared-contracts.mjs", "check:structure": "node scripts/validate-barrel-dir-siblings.mjs", + "check:release": "node --test scripts/release-notary-credentials.test.mjs scripts/release-package-arguments.test.mjs", "check:frontend": "npm --prefix frontend run check:quality", "check:controller": "cd controller && bun run typecheck && bun run lint && bun run check && bun run test", "check:agent-runtime": "cd services/agent-runtime && bun run test", diff --git a/scripts/release-notary-credentials.mjs b/scripts/release-notary-credentials.mjs new file mode 100644 index 000000000..3a9c2041e --- /dev/null +++ b/scripts/release-notary-credentials.mjs @@ -0,0 +1,31 @@ +function value(env, name) { + const candidate = env[name]; + return typeof candidate === "string" ? candidate.trim() : ""; +} + +export function resolveNotarytoolCredentials(env, apiKeyPath) { + const apiKey = value(env, "APPLE_API_KEY_BASE64"); + const apiKeyId = value(env, "APPLE_API_KEY_ID"); + const apiIssuer = value(env, "APPLE_API_ISSUER"); + if (apiKey && apiKeyId && apiIssuer) { + return { + kind: "api-key", + apiKey, + args: ["--key", apiKeyPath, "--key-id", apiKeyId, "--issuer", apiIssuer], + }; + } + + const appleId = value(env, "APPLE_ID"); + const password = value(env, "APPLE_APP_SPECIFIC_PASSWORD"); + const teamId = value(env, "APPLE_TEAM_ID"); + if (appleId && password && teamId) { + return { + kind: "apple-id", + args: ["--apple-id", appleId, "--password", password, "--team-id", teamId], + }; + } + + throw new Error( + "Apple notarization requires either the API key secret trio or the Apple ID secret trio", + ); +} diff --git a/scripts/release-notary-credentials.test.mjs b/scripts/release-notary-credentials.test.mjs new file mode 100644 index 000000000..ea6887cc4 --- /dev/null +++ b/scripts/release-notary-credentials.test.mjs @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { resolveNotarytoolCredentials } from "./release-notary-credentials.mjs"; + +test("uses App Store Connect API credentials when the full trio is present", () => { + assert.deepEqual( + resolveNotarytoolCredentials( + { + APPLE_API_KEY_BASE64: "encoded-key", + APPLE_API_KEY_ID: "key-id", + APPLE_API_ISSUER: "issuer", + }, + "/tmp/AuthKey.p8", + ), + { + kind: "api-key", + apiKey: "encoded-key", + args: ["--key", "/tmp/AuthKey.p8", "--key-id", "key-id", "--issuer", "issuer"], + }, + ); +}); + +test("uses Apple ID credentials when API credentials are unavailable", () => { + assert.deepEqual( + resolveNotarytoolCredentials( + { + APPLE_ID: "developer@example.com", + APPLE_APP_SPECIFIC_PASSWORD: "app-password", + APPLE_TEAM_ID: "team-id", + }, + "/tmp/AuthKey.p8", + ), + { + kind: "apple-id", + args: [ + "--apple-id", + "developer@example.com", + "--password", + "app-password", + "--team-id", + "team-id", + ], + }, + ); +}); + +test("rejects partial notarization credential sets", () => { + assert.throws( + () => resolveNotarytoolCredentials({ APPLE_ID: "developer@example.com" }, "/tmp/key.p8"), + /requires either the API key secret trio or the Apple ID secret trio/, + ); +}); diff --git a/scripts/release-package-arguments.mjs b/scripts/release-package-arguments.mjs new file mode 100644 index 000000000..113adef03 --- /dev/null +++ b/scripts/release-package-arguments.mjs @@ -0,0 +1,13 @@ +export const releasePackageArguments = ({ app, version, commit }) => [ + "--prepackaged", + app, + "--config", + "desktop/electron-builder.yml", + "--config.mac.identity=null", + "--config.mac.notarize=false", + "--config.dmg.sign=false", + `--config.extraMetadata.version=${version}`, + `--config.extraMetadata.localStudioCommit=${commit}`, + "--publish", + "never", +]; diff --git a/scripts/release-package-arguments.test.mjs b/scripts/release-package-arguments.test.mjs new file mode 100644 index 000000000..33f159f54 --- /dev/null +++ b/scripts/release-package-arguments.test.mjs @@ -0,0 +1,14 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { releasePackageArguments } from "./release-package-arguments.mjs"; + +test("release signing packaging never publishes implicitly", () => { + const args = releasePackageArguments({ + app: "/tmp/Local Studio.app", + version: "2.9.0", + commit: "0123456789abcdef", + }); + + assert.deepEqual(args.slice(-2), ["--publish", "never"]); + assert.deepEqual(args.slice(0, 2), ["--prepackaged", "/tmp/Local Studio.app"]); +}); diff --git a/scripts/sign-desktop-release.mjs b/scripts/sign-desktop-release.mjs index 63817ac1b..a9eb2b0f6 100644 --- a/scripts/sign-desktop-release.mjs +++ b/scripts/sign-desktop-release.mjs @@ -1,17 +1,12 @@ import { execFileSync } from "node:child_process"; import { randomBytes } from "node:crypto"; -import { - existsSync, - mkdirSync, - readFileSync, - rmSync, - symlinkSync, - writeFileSync, -} from "node:fs"; +import { existsSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { createRequire } from "node:module"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { resolveNotarytoolCredentials } from "./release-notary-credentials.mjs"; +import { releasePackageArguments } from "./release-package-arguments.mjs"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const frontend = path.join(root, "frontend"); @@ -46,8 +41,9 @@ function commandOutput(command, args) { } function keychainList() { - return [...commandOutput("security", ["list-keychains", "-d", "user"]).matchAll(/"([^"]+)"/g)] - .map((match) => match[1]); + return [ + ...commandOutput("security", ["list-keychains", "-d", "user"]).matchAll(/"([^"]+)"/g), + ].map((match) => match[1]); } function writeCertificate(link, destination) { @@ -66,7 +62,15 @@ function writeCertificate(link, destination) { async function refreshUpdateMetadata(output, version) { const { buildBlockMap } = require( - path.join(frontend, "node_modules", "app-builder-lib", "out", "targets", "blockmap", "blockmap.js"), + path.join( + frontend, + "node_modules", + "app-builder-lib", + "out", + "targets", + "blockmap", + "blockmap.js", + ), ); const YAML = require(path.join(frontend, "node_modules", "yaml")); const zipName = `Local Studio-${version}-arm64-mac.zip`; @@ -120,14 +124,12 @@ export async function signDesktopRelease(args = process.argv.slice(2)) { throw new Error("--prepackaged must point to an unsigned app bundle"); } - const apiKey = requireValue("APPLE_API_KEY_BASE64"); - const apiKeyId = requireValue("APPLE_API_KEY_ID"); - const apiIssuer = requireValue("APPLE_API_ISSUER"); const certificate = requireValue("CSC_LINK"); const certificatePassword = requireValue("CSC_KEY_PASSWORD"); const temporary = path.join(os.tmpdir(), `local-studio-release-${process.pid}`); - const apiKeyPath = path.join(temporary, `AuthKey_${apiKeyId}.p8`); + const apiKeyPath = path.join(temporary, "AuthKey_notary.p8"); + const notaryCredentials = resolveNotarytoolCredentials(process.env, apiKeyPath); const certificatePath = path.join(temporary, "developer-id.p12"); const keychainPath = path.join(temporary, "release-signing.keychain-db"); const keychainPassword = randomBytes(32).toString("hex"); @@ -140,7 +142,12 @@ export async function signDesktopRelease(args = process.argv.slice(2)) { try { rmSync(temporary, { recursive: true, force: true }); mkdirSync(temporary, { recursive: true, mode: 0o700 }); - writeFileSync(apiKeyPath, Buffer.from(apiKey, "base64"), { mode: 0o600, flag: "wx" }); + if (notaryCredentials.kind === "api-key") { + writeFileSync(apiKeyPath, Buffer.from(notaryCredentials.apiKey, "base64"), { + mode: 0o600, + flag: "wx", + }); + } writeCertificate(certificate, certificatePath); run("security", ["create-keychain", "-p", keychainPassword, keychainPath]); run("security", ["set-keychain-settings", "-lut", "21600", keychainPath]); @@ -175,7 +182,8 @@ export async function signDesktopRelease(args = process.argv.slice(2)) { keychainPath, ]); const identity = identityOutput.match(/"([^"]*Developer ID Application:[^"]*)"/)?.[1]; - if (!identity) throw new Error("Imported certificate does not contain a Developer ID Application identity"); + if (!identity) + throw new Error("Imported certificate does not contain a Developer ID Application identity"); const { signAsync } = require(path.join(frontend, "node_modules", "@electron", "osx-sign")); await signAsync({ @@ -202,24 +210,15 @@ export async function signDesktopRelease(args = process.argv.slice(2)) { ]); run("codesign", ["--verify", "--deep", "--strict", "--verbose=4", resolvedApp]); - process.env.APPLE_API_KEY = apiKeyPath; - process.env.APPLE_API_KEY_ID = apiKeyId; - process.env.APPLE_API_ISSUER = apiIssuer; process.env.LOCAL_STUDIO_RELEASE_VERSION = version; process.env.LOCAL_STUDIO_RELEASE_COMMIT = commit; process.env.CSC_IDENTITY_AUTO_DISCOVERY = "false"; - run(path.join(frontend, "node_modules", ".bin", "electron-builder"), [ - "--prepackaged", - resolvedApp, - "--config", - "desktop/electron-builder.yml", - "--config.mac.identity=null", - "--config.mac.notarize=false", - "--config.dmg.sign=false", - `--config.extraMetadata.version=${version}`, - `--config.extraMetadata.localStudioCommit=${commit}`, - ], { cwd: frontend }); + run( + path.join(frontend, "node_modules", ".bin", "electron-builder"), + releasePackageArguments({ app: resolvedApp, version, commit }), + { cwd: frontend }, + ); run("codesign", [ "--force", "--timestamp", @@ -233,12 +232,7 @@ export async function signDesktopRelease(args = process.argv.slice(2)) { "notarytool", "submit", dmg, - "--key", - apiKeyPath, - "--key-id", - apiKeyId, - "--issuer", - apiIssuer, + ...notaryCredentials.args, "--wait", "--output-format", "json", diff --git a/services/agent-runtime/src/sessions-store.ts b/services/agent-runtime/src/sessions-store.ts index 243608720..04bc0e54e 100644 --- a/services/agent-runtime/src/sessions-store.ts +++ b/services/agent-runtime/src/sessions-store.ts @@ -17,7 +17,10 @@ import { SettingsManager, } from "@earendil-works/pi-coding-agent"; import { resolveDataDir } from "./data-dir"; -import { cleanSessionTitle } from "../../../shared/agent/session-title"; +import { + cleanSessionTitle, + sessionTitleFromUserPrompt, +} from "../../../shared/agent/session-title"; import { readSessionListMetadata } from "./session-metadata-store"; import type { SessionSummary } from "../../../shared/agent/session-summary"; import { @@ -202,7 +205,8 @@ async function readSessionSummary( if (!firstUserMessage) { const userTurn = userTurnFromEvent(event); if (userTurn.isUser && userTurn.text) { - firstUserMessage = cleanSessionTitle(userTurn.text.slice(0, 120)) || null; + firstUserMessage = + cleanSessionTitle(sessionTitleFromUserPrompt(userTurn.text).slice(0, 120)) || null; } } if (header && firstUserMessage) break; diff --git a/services/agent-runtime/test/sessions-store.test.ts b/services/agent-runtime/test/sessions-store.test.ts index f6a034a43..8ed19e3ff 100644 --- a/services/agent-runtime/test/sessions-store.test.ts +++ b/services/agent-runtime/test/sessions-store.test.ts @@ -3,7 +3,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { SessionManager } from "@earendil-works/pi-coding-agent"; -import { findSessionFile, loadSession } from "../src/sessions-store"; +import { findSessionFile, listSessions, loadSession } from "../src/sessions-store"; const originalPiCodingAgentDir = process.env.PI_CODING_AGENT_DIR; const temporaryRoots: string[] = []; @@ -121,6 +121,36 @@ describe("findSessionFile", () => { }); }); +test("session summaries derive titles after internal browser context", async () => { + const { cwd, sessionDir } = createFixture(); + const sessionId = "019ee398-14e2-7ad1-af6c-f79b45dabacd"; + const filepath = writeSession(cwd, sessionDir, "2026-07-20T12-00-00-000Z", sessionId); + writeFileSync( + filepath, + [ + JSON.stringify({ + type: "session", + version: 3, + id: sessionId, + timestamp: "2026-07-20T12:00:00.000Z", + cwd, + }), + JSON.stringify({ + type: "message", + message: { + role: "user", + content: + "\nA server-side browser is available.\n\n\nReview the release status", + }, + }), + ].join("\n"), + ); + + const sessions = await listSessions(cwd); + + expect(sessions[0]?.firstUserMessage).toBe("Review the release status"); +}); + test("session replay follows Pi's active branch", async () => { const { cwd, sessionDir } = createFixture(); const manager = SessionManager.create(cwd, sessionDir, { id: "active-branch-session" }); diff --git a/shared/agent/session-title.ts b/shared/agent/session-title.ts index f903d01b5..d5a47c551 100644 --- a/shared/agent/session-title.ts +++ b/shared/agent/session-title.ts @@ -10,3 +10,15 @@ export function cleanSessionTitle(value: string | null | undefined): string { const normalized = value?.replace(/\s+/g, " ").trim() ?? ""; return normalized && !isPlaceholderSessionTitle(normalized) ? normalized : ""; } + +export function sessionTitleFromUserPrompt(value: string | null | undefined): string { + if (!value) return ""; + const marker = "\n\nUser prompt:\n"; + const markerIndex = value.lastIndexOf(marker); + const body = markerIndex === -1 ? value : value.slice(markerIndex + marker.length); + const visible = body.replace( + /^\s*(?:[\s\S]*?<\/browser_context>\s*|[\s\S]*$)/i, + "", + ); + return cleanSessionTitle(visible); +} diff --git a/site/README.md b/site/README.md deleted file mode 100644 index 8a51924e1..000000000 --- a/site/README.md +++ /dev/null @@ -1,12 +0,0 @@ -# Product site - -The landing page is static HTML, CSS, and JavaScript with no build step or -external runtime dependencies. - -Run `python3 -m http.server --directory site 8000` for a local preview. - -Pushes to `main` deploy `site/` to GitHub Pages. The macOS button links to -`https://localstudio.ai/download/macos`, which resolves to the newest GitHub -release that actually carries the signed `Local-Studio-arm64.dmg`, so releases -without desktop assets cannot break the public download. Windows and Linux -link to the releases page until installers ship. diff --git a/site/index.html b/site/index.html deleted file mode 100644 index f547b4785..000000000 --- a/site/index.html +++ /dev/null @@ -1,148 +0,0 @@ - - - - - -Local Studio — a home for your local AI - - - - - -
-
- LOCAL STUDIO - -
-
- -
- -
-
-

NATIVE DESKTOP · MACOS

-

A home for your models.

-

Local Studio runs your local AI the way you run the rest of your machine: - chat with local and remote models, hand real work to an agent, and operate your - GPUs from one screen. Your hardware, your weights, your desk.

- -

-
-
- -
- -
- -
-
-

START WITH THREE MODELS

-

Pick one to see the whole app working in minutes. All three install or connect with one click.

-
-
-
- Qwen3.6 35B - The daily driver. Strong general model with room for agent work. -
-
-
mode
local
-
backend
vLLM
-
quant
FP4
-
needs
~20 GB VRAM
-
-
-
-
- LFM2.5 8B - Small and quick. Runs well on a laptop; good first model. -
-
-
mode
local
-
backend
llama.cpp
-
size
~5 GB
-
needs
CPU or any GPU
-
-
-
-
- DeepSeek V4 Flash - Frontier-class over the wire. Nothing to download. -
-
-
mode
remote
-
backend
endpoint
-
size
0 B local
-
needs
an API key
-
-
-
-
-
- -
-
-

WHAT IT DOES

-
    -
  • - chat & agent - Talk to any model, or hand it the keyboard — the agent can read files, run commands, and use your computer. -
  • -
  • - operations - Launch, evict, and watch models with live VRAM, throughput, and controller state on one screen. -
  • -
  • - discover - Browse models with the right backend and quantization picked for your hardware; download in one click. -
  • -
  • - connect - Remote endpoints and your other machines sit in the same picker as local weights. -
  • -
  • - offline - Local models don't need the network, and neither does the app. -
  • -
-
-
- -
- - - - - - diff --git a/site/site.css b/site/site.css deleted file mode 100644 index 5601b5112..000000000 --- a/site/site.css +++ /dev/null @@ -1,246 +0,0 @@ -:root { - --bg: #0b0f14; - --bg-raise: #0e141b; - --hairline: #1d2833; - --text: #d7dee6; - --muted: #7c8894; - --faint: #55616d; - --accent: #dca24a; - --accent-dim: #8a6a34; - --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; - --mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; -} - -* { margin: 0; padding: 0; box-sizing: border-box; } - -html { scroll-behavior: smooth; } -@media (prefers-reduced-motion: reduce) { - html { scroll-behavior: auto; } -} - -body { - background: var(--bg); - color: var(--text); - font: 400 16px/1.6 var(--sans); - -webkit-font-smoothing: antialiased; -} - -.wrap { - max-width: 880px; - margin: 0 auto; - padding: 0 24px; -} - -a { color: var(--text); text-decoration: none; } -a:hover { color: var(--accent); } -a:focus-visible, .btn:focus-visible { - outline: 1px solid var(--accent); - outline-offset: 2px; -} - -.legend { - font: 500 11px/1 var(--mono); - letter-spacing: 0.14em; - color: var(--muted); -} - -.rule-legend { - display: flex; - align-items: center; - gap: 16px; - color: var(--accent); -} -.rule-legend::before { - content: ""; - width: 16px; - height: 1px; - background: var(--accent-dim); -} -.rule-legend::after { - content: ""; - flex: 1; - height: 1px; - background: var(--hairline); -} - -.masthead { - border-bottom: 1px solid var(--hairline); -} -.masthead-row { - display: flex; - align-items: baseline; - justify-content: space-between; - padding-top: 16px; - padding-bottom: 16px; -} -.wordmark { - font: 600 12px/1 var(--mono); - letter-spacing: 0.18em; - color: var(--text); -} -.masthead-nav { - display: flex; - gap: 24px; - font: 400 12px/1 var(--mono); - letter-spacing: 0.06em; -} -.masthead-nav a { color: var(--muted); } -.masthead-nav a:hover { color: var(--accent); } - -.hero .wrap { - padding-top: 96px; - padding-bottom: 96px; -} -.hero h1 { - font: 600 clamp(34px, 6vw, 56px)/1.1 var(--sans); - letter-spacing: -0.02em; - margin: 24px 0 24px; -} -.hero-sub { - max-width: 560px; - color: var(--muted); - margin-bottom: 40px; -} -.hero-actions { - display: flex; - align-items: center; - gap: 24px; - flex-wrap: wrap; -} -.btn { - display: inline-block; - font: 500 14px/1 var(--mono); - padding: 14px 24px; - border: 1px solid var(--accent-dim); -} -.btn-primary { - color: var(--bg); - background: var(--accent); - border-color: var(--accent); -} -.btn-primary:hover { color: var(--bg); background: #e8b565; } -.btn-quiet { - font: 400 12px/1 var(--mono); - color: var(--muted); - border-bottom: 1px solid var(--hairline); - padding-bottom: 2px; -} -.hero-alt { - margin-top: 16px; - font: 400 11px/1.5 var(--mono); - color: var(--faint); -} - -.sheet .wrap { padding-bottom: 88px; } -.sheet-note { - color: var(--muted); - font-size: 14px; - margin-top: 16px; -} - -.dl-grid { - margin-top: 32px; - border: 1px solid var(--hairline); -} -.dl-row { - display: grid; - grid-template-columns: 120px 1fr auto; - gap: 16px; - align-items: baseline; - padding: 16px 20px; - border-bottom: 1px solid var(--hairline); -} -.dl-row:last-child { border-bottom: 0; } -.dl-row:hover { background: var(--bg-raise); } -.dl-os { font: 500 14px/1.4 var(--sans); } -.dl-meta { font: 400 12px/1.4 var(--mono); color: var(--muted); } -.dl-go { font: 400 12px/1.4 var(--mono); color: var(--faint); } -.dl-row:hover .dl-go, .dl-row.is-current .dl-go { color: var(--accent); } -.dl-row.is-current .dl-os::after { - content: " · your OS"; - font: 400 11px/1 var(--mono); - color: var(--accent); -} - -.model-table { - margin-top: 32px; - border-top: 1px solid var(--hairline); -} -.model-row { - display: grid; - grid-template-columns: minmax(0, 5fr) minmax(0, 4fr); - gap: 24px; - padding: 24px 0; - border-bottom: 1px solid var(--hairline); -} -.model-title { - display: block; - font: 600 17px/1.3 var(--sans); -} -.model-desc { - display: block; - margin-top: 8px; - font-size: 14px; - color: var(--muted); -} -.model-meta { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 8px 16px; - align-content: start; - font: 400 12px/1.5 var(--mono); -} -.model-meta dt { - color: var(--faint); - letter-spacing: 0.08em; - text-transform: uppercase; - font-size: 10px; -} -.model-meta dd { color: var(--text); } - -.feature-list { - list-style: none; - margin-top: 32px; - border-top: 1px solid var(--hairline); -} -.feature-list li { - display: grid; - grid-template-columns: 160px 1fr; - gap: 24px; - padding: 16px 0; - border-bottom: 1px solid var(--hairline); -} -.feature-key { - font: 500 12px/1.8 var(--mono); - letter-spacing: 0.08em; - color: var(--accent); -} -.feature-body { - font-size: 14px; - color: var(--muted); -} - -.footer { border-top: 1px solid var(--hairline); } -.footer-row { - display: flex; - justify-content: space-between; - gap: 16px; - flex-wrap: wrap; - padding-top: 24px; - padding-bottom: 40px; - font: 400 12px/1.6 var(--mono); - color: var(--muted); -} -.footer-row a { color: var(--muted); border-bottom: 1px solid var(--hairline); } -.footer-row a:hover { color: var(--accent); } -.footer-quiet { color: var(--faint); } - -@media (max-width: 640px) { - .hero .wrap { padding-top: 56px; padding-bottom: 64px; } - .sheet .wrap { padding-bottom: 64px; } - .dl-row { grid-template-columns: 1fr auto; } - .dl-meta { grid-column: 1 / -1; } - .model-row { grid-template-columns: 1fr; gap: 16px; } - .feature-list li { grid-template-columns: 1fr; gap: 4px; } - .masthead-nav { gap: 16px; } -} diff --git a/site/site.js b/site/site.js deleted file mode 100644 index 61a8c8b74..000000000 --- a/site/site.js +++ /dev/null @@ -1,30 +0,0 @@ -(function () { - var ua = navigator.userAgent; - var os = /Mac/i.test(ua) ? "mac" : /Win/i.test(ua) ? "win" : /Linux|X11/i.test(ua) ? "linux" : null; - if (!os) return; - - var primary = document.getElementById("download-primary"); - var alt = document.getElementById("download-alt"); - - if (os === "mac") { - if (primary) primary.textContent = "Download for macOS (.dmg)"; - if (alt) alt.textContent = "Apple silicon. Windows and Linux builds are on the way."; - } else { - if (primary) { - primary.textContent = "See releases"; - primary.setAttribute( - "href", - "https://github.com/sybil-solutions/local-studio/releases" - ); - } - if (alt) { - alt.textContent = - os === "win" - ? "Windows installer is on the way — macOS (.dmg) is ready now." - : "Linux build is on the way — macOS (.dmg) is ready now."; - } - } - - var row = document.querySelector('.dl-row[data-os="' + os + '"]'); - if (row) row.classList.add("is-current"); -})();