Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
168cad7470 | ||
|
|
ce28d95bcf | ||
|
|
70977bad84 | ||
|
|
fdd63b27a9 | ||
|
|
bdd7d5e866 | ||
|
|
1c6ae6b2e2 | ||
|
|
d2d9fcd09e | ||
|
|
a65867fc39 | ||
|
|
8c564946ef | ||
|
|
f8ce0f21ef | ||
|
|
2da091d6e9 | ||
|
|
674d4bd8c6 | ||
|
|
a50ca4b867 | ||
|
|
b277ee3e0f | ||
|
|
9171a4f49c | ||
|
|
7fb9a1505c | ||
|
|
3cc598ded5 | ||
|
|
55a6501a09 | ||
|
|
d6e317c6fe | ||
|
|
0a9dabfdc2 | ||
|
|
5cf4e589be | ||
|
|
671d2c8bb7 | ||
|
|
e8c81faad9 | ||
|
|
480a29aaea | ||
|
|
c549494488 | ||
|
|
74b0c65843 | ||
|
|
04037e98d6 | ||
|
|
36338b3b55 | ||
|
|
8946209f98 | ||
|
|
624c91a437 | ||
|
|
24d1dda07d | ||
|
|
50a2db775d | ||
|
|
6788a01add | ||
|
|
102aa6f7ae | ||
|
|
42abeb65ae | ||
|
|
8691470d03 | ||
|
|
b8abad2b4f | ||
|
|
0fc6d4ab94 | ||
|
|
ae2fad56c4 | ||
|
|
b6bc9edcda | ||
|
|
480064b19e | ||
|
|
7fb891389d | ||
|
|
4bd20be529 |
@@ -0,0 +1,59 @@
|
|||||||
|
name: Linux desktop release
|
||||||
|
|
||||||
|
# Manual until a native Linux runner is registered. The current 0.5.2
|
||||||
|
# packages are built locally using the identical gates documented below.
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: Existing stable version tag to package (for example v0.5.2)
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
releases: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: gitea-release-${{ inputs.tag }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
linux:
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
timeout-minutes: 60
|
||||||
|
env:
|
||||||
|
NEXT_TELEMETRY_DISABLED: "1"
|
||||||
|
steps:
|
||||||
|
- uses: https://github.com/actions/checkout@v7
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.tag }}
|
||||||
|
persist-credentials: false
|
||||||
|
- uses: https://github.com/actions/setup-node@v7
|
||||||
|
with:
|
||||||
|
node-version: "26"
|
||||||
|
cache: npm
|
||||||
|
- name: Tag must match package.json
|
||||||
|
env:
|
||||||
|
RELEASE_TAG: ${{ inputs.tag }}
|
||||||
|
run: node --input-type=module -e 'import fs from "node:fs"; if (process.env.RELEASE_TAG !== `v${JSON.parse(fs.readFileSync("package.json")).version}`) process.exit(1)'
|
||||||
|
- run: npm ci
|
||||||
|
- run: npm run typecheck
|
||||||
|
- run: npm test
|
||||||
|
- run: npm run lint
|
||||||
|
- name: Native PDF and SVG renderers
|
||||||
|
run: npx electron --import tsx --test tests/pdf-native-render.test.ts tests/pdf-ops.test.ts tests/svg-native-render.test.ts tests/browser-service.test.ts
|
||||||
|
env:
|
||||||
|
NODE_ENV: test
|
||||||
|
ELECTRON_RUN_AS_NODE: "1"
|
||||||
|
- name: Build, smoke, package, and verify Linux installers
|
||||||
|
run: npm run desktop:release:linux
|
||||||
|
- name: Verify the packaged provider and demo runtimes
|
||||||
|
run: |
|
||||||
|
node scripts/smoke-provider-runtime.mjs --standalone dist/linux-unpacked/resources/server/standalone
|
||||||
|
node scripts/smoke-provider-runtime.mjs --standalone dist/linux-unpacked/resources/server/standalone --runtime dist/linux-unpacked/learnharness
|
||||||
|
xvfb-run -a node scripts/smoke-demo-runtime.mjs --standalone dist/linux-unpacked/resources/server/standalone --runtime dist/linux-unpacked/learnharness --browser
|
||||||
|
- name: Upload and verify the draft (never publish)
|
||||||
|
run: node scripts/gitea-release.mjs upload dist
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
@@ -63,5 +63,7 @@ jobs:
|
|||||||
NODE_ENV: test
|
NODE_ENV: test
|
||||||
ELECTRON_RUN_AS_NODE: "1"
|
ELECTRON_RUN_AS_NODE: "1"
|
||||||
- run: npm run build
|
- run: npm run build
|
||||||
|
# Check the fresh trace before any static copying/curation.
|
||||||
|
- run: node scripts/smoke-provider-runtime.mjs
|
||||||
- run: node scripts/build-server.mjs
|
- run: node scripts/build-server.mjs
|
||||||
- run: node scripts/smoke-server.mjs --electron
|
- run: node scripts/smoke-server.mjs --electron
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
name: Notify website of public desktop release
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
|
||||||
|
# Use only the separately scoped website token; no app token or checkout is needed.
|
||||||
|
permissions: {}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
notify-website:
|
||||||
|
if: >-
|
||||||
|
github.repository == 'amosblomqvist/learnharness' &&
|
||||||
|
!github.event.release.draft && !github.event.release.prerelease
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
steps:
|
||||||
|
- name: Queue the website update for the published tag
|
||||||
|
env:
|
||||||
|
WEBSITE_DISPATCH_TOKEN: ${{ secrets.WEBSITE_DISPATCH_TOKEN }}
|
||||||
|
RELEASE_TAG: ${{ github.event.release.tag_name }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [[ -z "$WEBSITE_DISPATCH_TOKEN" ]]; then
|
||||||
|
echo '::error::Missing WEBSITE_DISPATCH_TOKEN.'
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ -z "$RELEASE_TAG" ]]; then
|
||||||
|
echo '::error::The published release has no tag.'
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
payload=$(jq -cn --arg tag "$RELEASE_TAG" \
|
||||||
|
'{event_type:"desktop-release-published",client_payload:{tag:$tag}}')
|
||||||
|
status=$(curl --fail-with-body --silent --show-error \
|
||||||
|
--connect-timeout 15 --max-time 60 \
|
||||||
|
--request POST \
|
||||||
|
--header 'Accept: application/vnd.github+json' \
|
||||||
|
--header "Authorization: Bearer $WEBSITE_DISPATCH_TOKEN" \
|
||||||
|
--header 'X-GitHub-Api-Version: 2022-11-28' \
|
||||||
|
--header 'Content-Type: application/json' \
|
||||||
|
--data "$payload" \
|
||||||
|
--output /dev/null --write-out '%{http_code}' \
|
||||||
|
https://api.github.com/repos/amosblomqvist/learnharness-web/dispatches)
|
||||||
|
if [[ "$status" != '204' ]]; then
|
||||||
|
printf '::error::Expected dispatch HTTP 204, received %s.\n' "$status"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo 'Website update queued. This does not confirm website deployment.'
|
||||||
@@ -129,6 +129,8 @@ jobs:
|
|||||||
NODE_ENV: test
|
NODE_ENV: test
|
||||||
ELECTRON_RUN_AS_NODE: "1"
|
ELECTRON_RUN_AS_NODE: "1"
|
||||||
- run: npm run build
|
- run: npm run build
|
||||||
|
# Check the fresh trace before any static copying/curation.
|
||||||
|
- run: node scripts/smoke-provider-runtime.mjs
|
||||||
- run: node scripts/build-server.mjs
|
- run: node scripts/build-server.mjs
|
||||||
- run: node scripts/smoke-server.mjs --electron
|
- run: node scripts/smoke-server.mjs --electron
|
||||||
- name: Validate macOS release secrets
|
- name: Validate macOS release secrets
|
||||||
@@ -169,6 +171,25 @@ jobs:
|
|||||||
- name: Windows/Linux rehearsal (no publish)
|
- name: Windows/Linux rehearsal (no publish)
|
||||||
if: github.event_name == 'workflow_dispatch' && runner.os != 'macOS'
|
if: github.event_name == 'workflow_dispatch' && runner.os != 'macOS'
|
||||||
run: npx electron-builder ${{ matrix.builder_args }} --publish never
|
run: npx electron-builder ${{ matrix.builder_args }} --publish never
|
||||||
|
- name: Initialize packaged providers offline (host and packaged Electron Node)
|
||||||
|
run: |
|
||||||
|
case "$RUNNER_OS" in
|
||||||
|
macOS)
|
||||||
|
app="dist/mac-arm64/LearnHarness.app/Contents"
|
||||||
|
payload="$app/Resources/server/standalone"
|
||||||
|
runtime="$app/MacOS/LearnHarness"
|
||||||
|
;;
|
||||||
|
Windows)
|
||||||
|
payload="dist/win-unpacked/resources/server/standalone"
|
||||||
|
runtime="dist/win-unpacked/LearnHarness.exe"
|
||||||
|
;;
|
||||||
|
Linux)
|
||||||
|
payload="dist/linux-unpacked/resources/server/standalone"
|
||||||
|
runtime="dist/linux-unpacked/learnharness"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
node scripts/smoke-provider-runtime.mjs --standalone "$payload"
|
||||||
|
node scripts/smoke-provider-runtime.mjs --standalone "$payload" --runtime "$runtime"
|
||||||
- name: Exercise packaged demo runtime (own Electron, no system Chrome)
|
- name: Exercise packaged demo runtime (own Electron, no system Chrome)
|
||||||
run: |
|
run: |
|
||||||
case "$RUNNER_OS" in
|
case "$RUNNER_OS" in
|
||||||
|
|||||||
+2
-1
@@ -4,7 +4,8 @@ data/
|
|||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
.env.local
|
.env
|
||||||
|
.env.*
|
||||||
.memory/
|
.memory/
|
||||||
SCOPE.md
|
SCOPE.md
|
||||||
# raw real-data import sources (user directive 2026-09-13: never commit)
|
# raw real-data import sources (user directive 2026-09-13: never commit)
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* GET /api/activity — the dock's one poll: every running background
|
* GET /api/activity — the dock's one poll: every running background
|
||||||
* process, every view (chat, home, everywhere). Composition only — the
|
* process, every view (chat, home, everywhere). Composition only — the
|
||||||
* feed lives in server/observability.ts; cost folds client-side via
|
* feed lives in server/observability.ts; costs are projected server-side
|
||||||
* core/prices.ts. See that module's header for why /api/jobs stays
|
* through Pi's catalog and public calculator. See that module's header for why /api/jobs stays
|
||||||
* chat-scoped and untouched.
|
* chat-scoped and untouched.
|
||||||
*/
|
*/
|
||||||
import { activityFeed } from "@/src/server";
|
import { activityFeed } from "@/src/server";
|
||||||
|
|||||||
@@ -11,14 +11,14 @@
|
|||||||
* markers are invisible by the engine's law).
|
* markers are invisible by the engine's law).
|
||||||
*/
|
*/
|
||||||
import { sessionMarkdown } from "@/src/core";
|
import { sessionMarkdown } from "@/src/core";
|
||||||
import { getChat } from "@/src/server";
|
import { getChat, applicationLanguage } from "@/src/server";
|
||||||
|
|
||||||
/** Filename-safe title (own module scope on purpose: tiny + pure). */
|
/** Filename-safe title (own module scope on purpose: tiny + pure). */
|
||||||
function slugify(title: string): string {
|
function slugify(title: string): string {
|
||||||
const slug = title
|
const slug = title
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.normalize("NFKD")
|
.normalize("NFKD")
|
||||||
.replace(/[^a-z0-9]+/g, "-")
|
.replace(/[^\p{L}\p{N}]+/gu, "-")
|
||||||
.replace(/^-+|-+$/g, "");
|
.replace(/^-+|-+$/g, "");
|
||||||
return slug.length > 0 ? slug : "session";
|
return slug.length > 0 ? slug : "session";
|
||||||
}
|
}
|
||||||
@@ -27,11 +27,11 @@ export async function GET(_req: Request, ctx: { params: Promise<{ id: string }>
|
|||||||
const { id } = await ctx.params;
|
const { id } = await ctx.params;
|
||||||
const record = getChat(id);
|
const record = getChat(id);
|
||||||
if (!record) return Response.json({ error: "Unknown chat" }, { status: 404 });
|
if (!record) return Response.json({ error: "Unknown chat" }, { status: 404 });
|
||||||
const markdown = sessionMarkdown(record.messages, { title: record.title, anchors: false });
|
const markdown = sessionMarkdown(record.messages, { title: record.title, anchors: false, language: applicationLanguage(_req.headers.get("accept-language")) });
|
||||||
return new Response(markdown, {
|
return new Response(markdown, {
|
||||||
headers: {
|
headers: {
|
||||||
"content-type": "text/markdown; charset=utf-8",
|
"content-type": "text/markdown; charset=utf-8",
|
||||||
"content-disposition": `attachment; filename="${slugify(record.title)}.md"`,
|
"content-disposition": `attachment; filename="${/^[a-z0-9-]+$/.test(slugify(record.title)) ? slugify(record.title) : "session"}.md"; filename*=UTF-8''${encodeURIComponent(slugify(record.title))}.md`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { compactionInFlight, deleteChatSession, getChat, isTurnRunning, sessionCoverage, stores, type ChatDeleteMode } from "@/src/server";
|
import { compactionInFlight, deleteChatSession, getChat, isTurnRunning, sessionCoverage, sessionCost, stores, type ChatDeleteMode } from "@/src/server";
|
||||||
|
|
||||||
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
@@ -17,6 +17,7 @@ export async function GET(_req: Request, { params }: { params: Promise<{ id: str
|
|||||||
return Response.json({
|
return Response.json({
|
||||||
...chat,
|
...chat,
|
||||||
messages: chat.messages,
|
messages: chat.messages,
|
||||||
|
pricing: sessionCost(chat),
|
||||||
turnInFlight: isTurnRunning(id),
|
turnInFlight: isTurnRunning(id),
|
||||||
compacting: compactionInFlight(id),
|
compacting: compactionInFlight(id),
|
||||||
coverage: sessionCoverage(id),
|
coverage: sessionCoverage(id),
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* three usage channels (chat extraction runs, notes-source runs,
|
* three usage channels (chat extraction runs, notes-source runs,
|
||||||
* cluster-naming runs). Thin transport over src/server's graphCosts();
|
* cluster-naming runs). Thin transport over src/server's graphCosts();
|
||||||
* the fold itself is pure core (core/cost-summary.ts). Dollars are
|
* the fold itself is pure core (core/cost-summary.ts). Dollars are
|
||||||
* inferred from PRICES at read time — never stored, so a price sync
|
* inferred from Pi's catalog/calculator at read time — never stored, so a catalog update
|
||||||
* re-prices the whole history for free.
|
* re-prices the whole history for free.
|
||||||
*
|
*
|
||||||
* Scope (user-decided): the graph pipeline ONLY — teaching turns and
|
* Scope (user-decided): the graph pipeline ONLY — teaching turns and
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
* healthy. The dock's chips + graph refetch track progress live through
|
* healthy. The dock's chips + graph refetch track progress live through
|
||||||
* /api/graph/status, as with session runs.
|
* /api/graph/status, as with session runs.
|
||||||
*/
|
*/
|
||||||
import { extraction, NotesImportError } from "@/src/server";
|
import { extraction, NotesImportError, applicationLanguage } from "@/src/server";
|
||||||
import {
|
import {
|
||||||
ModelsNotConfiguredError,
|
ModelsNotConfiguredError,
|
||||||
PROVIDER_KEY_MISSING_CODE,
|
PROVIDER_KEY_MISSING_CODE,
|
||||||
@@ -56,7 +56,7 @@ export async function POST(request: Request) {
|
|||||||
const { file, requestIndex } = next;
|
const { file, requestIndex } = next;
|
||||||
try {
|
try {
|
||||||
const text = await file.text();
|
const text = await file.text();
|
||||||
const stats = await extraction.importNotes({ filePath: file.name, text });
|
const stats = await extraction.importNotes({ filePath: file.name, text, language: applicationLanguage(request.headers.get("accept-language")) });
|
||||||
results.push({ filePath: file.name, requestIndex, outcome: "completed", ok: true, stats });
|
results.push({ filePath: file.name, requestIndex, outcome: "completed", ok: true, stats });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ModelsNotConfiguredError) {
|
if (err instanceof ModelsNotConfiguredError) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { modelCatalog } from "@/src/server";
|
import { modelCatalog, refreshConnectionCatalog } from "@/src/server";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The model picker's data (model-control wave): pi-ai's configured-provider
|
* The model picker's data (model-control wave): pi-ai's configured-provider
|
||||||
@@ -7,5 +7,6 @@ import { modelCatalog } from "@/src/server";
|
|||||||
* catalog: no network, stable within a pi-ai version.
|
* catalog: no network, stable within a pi-ai version.
|
||||||
*/
|
*/
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
return Response.json(modelCatalog());
|
await refreshConnectionCatalog();
|
||||||
|
return Response.json(modelCatalog(), { headers: { "Cache-Control": "no-store" } });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -598,3 +598,6 @@ body {
|
|||||||
outline: 1px solid var(--border);
|
outline: 1px solid var(--border);
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Source Sans 3 lacks Cyrillic; Russian prose uses the bundled Noto Sans subset. */
|
||||||
|
html[lang="ru"] { --font-sans3: var(--font-russian-prose); }
|
||||||
|
|||||||
+22
-9
@@ -1,5 +1,9 @@
|
|||||||
|
import { headers } from "next/headers";
|
||||||
|
import { readSettingsRecord } from "@/src/server";
|
||||||
|
import { resolveLanguage, systemLanguage, translate } from "@/src/core";
|
||||||
|
import { I18nProvider } from "@/components/i18n";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { JetBrains_Mono, Source_Sans_3 } from "next/font/google";
|
import { JetBrains_Mono, Source_Sans_3, Noto_Sans } from "next/font/google";
|
||||||
import "katex/dist/katex.min.css";
|
import "katex/dist/katex.min.css";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { AppShell, SidebarProvider } from "@/components/sidebar";
|
import { AppShell, SidebarProvider } from "@/components/sidebar";
|
||||||
@@ -10,21 +14,29 @@ import UpdateBanner from "@/components/update-banner";
|
|||||||
|
|
||||||
// Chrome = JetBrains Mono, teaching prose = Source Sans 3, titles = CMU
|
// Chrome = JetBrains Mono, teaching prose = Source Sans 3, titles = CMU
|
||||||
// Serif (the bundled lh-cmu @font-face in globals.css — no CDN).
|
// Serif (the bundled lh-cmu @font-face in globals.css — no CDN).
|
||||||
const ui = JetBrains_Mono({ subsets: ["latin"], variable: "--font-jetbrains" });
|
const ui = JetBrains_Mono({ subsets: ["latin", "cyrillic"], variable: "--font-jetbrains" });
|
||||||
const prose = Source_Sans_3({ subsets: ["latin"], variable: "--font-sans3" });
|
const prose = Source_Sans_3({ subsets: ["latin"], variable: "--font-sans3" });
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
const russianProse = Noto_Sans({ subsets: ["latin", "cyrillic"], variable: "--font-russian-prose" });
|
||||||
title: "Learning Harness",
|
|
||||||
description: "A teacher agent with a memory — sessions, quizzes, and a knowledge graph of what you know.",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const language = resolveLanguage(readSettingsRecord()?.general?.language, (await headers()).get("accept-language"));
|
||||||
|
return {
|
||||||
|
title: "Learning Harness",
|
||||||
|
description: translate(language, "A teacher agent with a memory — sessions, quizzes, and a knowledge graph of what you know."),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const general = readSettingsRecord()?.general ?? {};
|
||||||
|
const system = systemLanguage((await headers()).get("accept-language"));
|
||||||
|
const language = resolveLanguage(general.language, system);
|
||||||
return (
|
return (
|
||||||
<html lang="en" className={`dark ${ui.variable} ${prose.variable}`}>
|
<html lang={language} className={`dark ${ui.variable} ${prose.variable} ${russianProse.variable}`}>
|
||||||
<body className="min-h-dvh antialiased">
|
<body className="min-h-dvh antialiased">
|
||||||
{/* The notification channel rides OUTSIDE everything: sidebar, dock,
|
{/* The notification channel rides OUTSIDE everything: sidebar, dock,
|
||||||
and pages all subscribe as the polls retire (push S3, 2026-10-02). */}
|
and pages all subscribe as the polls retire (push S3, 2026-10-02). */}
|
||||||
<ServerEventsProvider>
|
<I18nProvider initialGeneral={general} system={system}><ServerEventsProvider>
|
||||||
<AppearanceProvider>
|
<AppearanceProvider>
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
<ActivityProvider>
|
<ActivityProvider>
|
||||||
@@ -35,6 +47,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|||||||
</ServerEventsProvider>
|
</ServerEventsProvider>
|
||||||
{/* Auto-update banner (Wave C): fixed card, Electron-only pushes. */}
|
{/* Auto-update banner (Wave C): fixed card, Electron-only pushes. */}
|
||||||
<UpdateBanner />
|
<UpdateBanner />
|
||||||
|
</I18nProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { useI18n } from "@/components/i18n";
|
||||||
|
|
||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
@@ -60,13 +62,14 @@ export function AbortTurnBody({
|
|||||||
* (owner, 2026-10-04): the line says so. */
|
* (owner, 2026-10-04): the line says so. */
|
||||||
busy?: boolean;
|
busy?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
const backdropDismiss = useBackdropDismiss(onClose);
|
const backdropDismiss = useBackdropDismiss(onClose);
|
||||||
const copy = COPY[mode];
|
const copy = COPY[mode];
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label={copy.title}
|
aria-label={t(copy.title)}
|
||||||
data-testid="abort-turn-dialog"
|
data-testid="abort-turn-dialog"
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm"
|
className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm"
|
||||||
// A genuine click on the scrim cancels — the cautious default, like Esc.
|
// A genuine click on the scrim cancels — the cautious default, like Esc.
|
||||||
@@ -77,11 +80,11 @@ export function AbortTurnBody({
|
|||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<h2 className="text-sm font-semibold text-card-foreground" data-testid="abort-turn-title">
|
<h2 className="text-sm font-semibold text-card-foreground" data-testid="abort-turn-title">
|
||||||
{copy.title}
|
{t(copy.title)}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-1.5 text-xs text-muted-foreground" data-testid="abort-turn-line">
|
<p className="mt-1.5 text-xs text-muted-foreground" data-testid="abort-turn-line">
|
||||||
{copy.line}
|
{t(copy.line)}
|
||||||
{busy && mode !== "turn" ? " The working turn stops too." : ""}
|
{busy && mode !== "turn" ? t(" The working turn stops too.") : ""}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-4 flex justify-end gap-2">
|
<div className="mt-4 flex justify-end gap-2">
|
||||||
<button
|
<button
|
||||||
@@ -90,8 +93,7 @@ export function AbortTurnBody({
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="rounded-sm px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
|
className="rounded-sm px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||||
>
|
>
|
||||||
Cancel
|
{t("Cancel")}</button>
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="abort-turn-confirm"
|
data-testid="abort-turn-confirm"
|
||||||
@@ -99,7 +101,7 @@ export function AbortTurnBody({
|
|||||||
// Destructive red — the app's established destructive pair.
|
// Destructive red — the app's established destructive pair.
|
||||||
className="rounded-sm border border-destructive/50 bg-destructive/10 px-3 py-1.5 text-xs text-destructive transition-colors hover:bg-destructive/20"
|
className="rounded-sm border border-destructive/50 bg-destructive/10 px-3 py-1.5 text-xs text-destructive transition-colors hover:bg-destructive/20"
|
||||||
>
|
>
|
||||||
{copy.action}
|
{t(copy.action)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { translator, type Language } from "@/src/core";
|
||||||
|
import { useI18n } from "@/components/i18n";
|
||||||
|
|
||||||
|
|
||||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { ActivityIcon, BotIcon, FileTextIcon, PenToolIcon, PlayIcon, SearchIcon, SquareIcon, WaypointsIcon } from "lucide-react";
|
import { ActivityIcon, BotIcon, FileTextIcon, PenToolIcon, PlayIcon, SearchIcon, SquareIcon, WaypointsIcon } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { CallUsage, JobKind, JobRecord } from "@/src/core";
|
import type { CallUsage, JobKind, JobRecord } from "@/src/core";
|
||||||
import { isModelsRefusal, isProviderKeyRefusal, jobCost } from "@/src/core";
|
import { isModelsRefusal, isProviderKeyRefusal, subscriptionLabel, subscriptionLabels } from "@/src/core";
|
||||||
import { formatTokens, formatUsd } from "@/components/usage-format";
|
import { formatTokens, planCostLabel } from "@/components/usage-format";
|
||||||
import { useServerEvent, useServerResync } from "@/components/server-events";
|
import { useServerEvent, useServerResync } from "@/components/server-events";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -20,7 +23,7 @@ import { useServerEvent, useServerResync } from "@/components/server-events";
|
|||||||
* Data: ONE feed of GET /api/activity (jobs across ALL chats, title-joined,
|
* Data: ONE feed of GET /api/activity (jobs across ALL chats, title-joined,
|
||||||
* oldest-first + extraction runs), fetched when truth moves (events
|
* oldest-first + extraction runs), fetched when truth moves (events
|
||||||
* channel, S5) and folded client-side with core/prices.ts.
|
* channel, S5) and folded client-side with core/prices.ts.
|
||||||
* Cost is INFERRED (jobCost); the context line is the LAST model call's
|
* Cost is INFERRED server-side through Pi; the context line is the LAST model call's
|
||||||
* input+output against the model's limit (JobUsage.lastStep — pending
|
* input+output against the model's limit (JobUsage.lastStep — pending
|
||||||
* contract) — never a cumulative sum, which would be the wrong number.
|
* contract) — never a cumulative sum, which would be the wrong number.
|
||||||
* Absent data renders "—", never zero (the doctrine).
|
* Absent data renders "—", never zero (the doctrine).
|
||||||
@@ -48,6 +51,8 @@ interface FeedUsage extends NonNullable<JobRecord["usage"]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface FeedJob extends JobRecord {
|
export interface FeedJob extends JobRecord {
|
||||||
|
/** Read-time server estimate; absent on older transport payloads. */
|
||||||
|
costUsd?: number | null;
|
||||||
title: string;
|
title: string;
|
||||||
usage?: FeedUsage;
|
usage?: FeedUsage;
|
||||||
/** Server-joined live catalog limit (owner amendment 2026-09-23) —
|
/** Server-joined live catalog limit (owner amendment 2026-09-23) —
|
||||||
@@ -200,7 +205,7 @@ export function toBlobs(feed: ActivityFeed): ActivityBlob[] {
|
|||||||
// blob shape's call — here the line simply hides).
|
// blob shape's call — here the line simply hides).
|
||||||
const limit = job.contextLimit;
|
const limit = job.contextLimit;
|
||||||
if (usage && lastStep && limit !== null && lastStep.inputTokens !== undefined) {
|
if (usage && lastStep && limit !== null && lastStep.inputTokens !== undefined) {
|
||||||
// Same metric as the session meter (prices.ts stepFootprint — the
|
// Same metric as the session meter (four normalized buckets — the
|
||||||
// context-meter audit, owner 2026-09-23): all four directions.
|
// context-meter audit, owner 2026-09-23): all four directions.
|
||||||
// Provider "input" EXCLUDES cacheRead/cacheWrite, so input+output
|
// Provider "input" EXCLUDES cacheRead/cacheWrite, so input+output
|
||||||
// alone reads wildly low on cache-heavy jobs.
|
// alone reads wildly low on cache-heavy jobs.
|
||||||
@@ -220,7 +225,7 @@ export function toBlobs(feed: ActivityFeed): ActivityBlob[] {
|
|||||||
title: job.title,
|
title: job.title,
|
||||||
chatId: job.chatId,
|
chatId: job.chatId,
|
||||||
startedAt: Date.parse(job.createdAt) || 0,
|
startedAt: Date.parse(job.createdAt) || 0,
|
||||||
cost: jobCost(job),
|
cost: job.costUsd ?? null,
|
||||||
context,
|
context,
|
||||||
// BYOK (owner, 2026-09-22): a refusal is info, not failure —
|
// BYOK (owner, 2026-09-22): a refusal is info, not failure —
|
||||||
// the honest line the runner left ("failed") reads as setup
|
// the honest line the runner left ("failed") reads as setup
|
||||||
@@ -255,12 +260,13 @@ export function toBlobs(feed: ActivityFeed): ActivityBlob[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Elapsed as a compact ticking string: 45s / 3m 04s / 1h 12m. */
|
/** Elapsed as a compact ticking string: 45s / 3m 04s / 1h 12m. */
|
||||||
export function formatElapsed(ms: number): string {
|
export function formatElapsed(ms: number, language: Language = "en"): string {
|
||||||
|
const t = translator(language);
|
||||||
const s = Math.max(0, Math.floor(ms / 1000));
|
const s = Math.max(0, Math.floor(ms / 1000));
|
||||||
if (s < 60) return `${s}s`;
|
if (s < 60) return t("{v0}s", { v0: s });
|
||||||
const m = Math.floor(s / 60);
|
const m = Math.floor(s / 60);
|
||||||
if (m < 60) return `${m}m ${String(s % 60).padStart(2, "0")}s`;
|
if (m < 60) return t("{v0}m {v1}s", { v0: m, v1: String(s % 60).padStart(2, "0") });
|
||||||
return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, "0")}m`;
|
return t("{v0}h {v1}m", { v0: Math.floor(m / 60), v1: String(m % 60).padStart(2, "0") });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── The section (the sidebar's bottom strip) ─────────────────────────────────
|
// ─── The section (the sidebar's bottom strip) ─────────────────────────────────
|
||||||
@@ -287,6 +293,7 @@ const KIND_ICON: Record<JobKind, typeof SearchIcon> = {
|
|||||||
type AbortState = "pending" | "failed";
|
type AbortState = "pending" | "failed";
|
||||||
|
|
||||||
export function ActivitySection({ feed: feedProp }: { feed?: ActivityFeed }) {
|
export function ActivitySection({ feed: feedProp }: { feed?: ActivityFeed }) {
|
||||||
|
const { t } = useI18n();
|
||||||
const ctx = useContext(ActivityContext);
|
const ctx = useContext(ActivityContext);
|
||||||
const feed = feedProp ?? ctx.feed;
|
const feed = feedProp ?? ctx.feed;
|
||||||
const refresh = ctx.refresh;
|
const refresh = ctx.refresh;
|
||||||
@@ -345,10 +352,9 @@ export function ActivitySection({ feed: feedProp }: { feed?: ActivityFeed }) {
|
|||||||
<section data-testid="activity-section" className="border-t border-border px-3 pb-3 pt-2">
|
<section data-testid="activity-section" className="border-t border-border px-3 pb-3 pt-2">
|
||||||
<div className="mb-1 flex items-center gap-1.5 text-xs uppercase tracking-widest text-muted-foreground/70">
|
<div className="mb-1 flex items-center gap-1.5 text-xs uppercase tracking-widest text-muted-foreground/70">
|
||||||
<ActivityIcon className="size-3" />
|
<ActivityIcon className="size-3" />
|
||||||
Background agents
|
{t("Background agents")}{blobs.length > 0 && (
|
||||||
{blobs.length > 0 && (
|
<span className="ml-auto tabular-nums" data-testid="activity-total-cost" title={t("Total running cost (inferred)")}>
|
||||||
<span className="ml-auto tabular-nums" data-testid="activity-total-cost" title="Total running cost (inferred)">
|
{planCostLabel(totalCost, subscriptionLabels(blobs.map((blob) => blob.model)))}
|
||||||
{totalCost === null ? "—" : formatUsd(totalCost)}
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -387,19 +393,20 @@ function ActivityBlobRow({
|
|||||||
abortState: AbortState | undefined;
|
abortState: AbortState | undefined;
|
||||||
onAbort: () => void;
|
onAbort: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t, language } = useI18n();
|
||||||
const Icon = blob.process === "extraction" ? WaypointsIcon : (blob.jobKind && KIND_ICON[blob.jobKind]) || BotIcon;
|
const Icon = blob.process === "extraction" ? WaypointsIcon : (blob.jobKind && KIND_ICON[blob.jobKind]) || BotIcon;
|
||||||
const label =
|
const label =
|
||||||
blob.process === "extraction"
|
blob.process === "extraction"
|
||||||
? `Extraction — ${blob.phase ?? "running"}`
|
? t("Extraction — {v0}", { v0: t(blob.phase ?? "running") })
|
||||||
: blob.jobKind === "research"
|
: blob.jobKind === "research"
|
||||||
? "Researcher"
|
? t("Researcher")
|
||||||
: blob.jobKind === "demo"
|
: blob.jobKind === "demo"
|
||||||
? "Demo builder"
|
? t("Demo builder")
|
||||||
: blob.jobKind === "visual"
|
: blob.jobKind === "visual"
|
||||||
? "Figure maker"
|
? t("Figure maker")
|
||||||
: blob.jobKind === "pdf"
|
: blob.jobKind === "pdf"
|
||||||
? "PDF reader"
|
? t("PDF reader")
|
||||||
: "Sub-agent";
|
: t("Sub-agent");
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-testid={`activity-blob-${blob.process}`}
|
data-testid={`activity-blob-${blob.process}`}
|
||||||
@@ -415,16 +422,16 @@ function ActivityBlobRow({
|
|||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
<span className="block truncate font-medium text-card-foreground">{blob.title}</span>
|
<span className="block truncate font-medium text-card-foreground">{blob.title}</span>
|
||||||
{blob.activity ? (
|
{blob.activity ? (
|
||||||
<span className="block truncate text-muted-foreground" title={blob.activity}>
|
<span className="block truncate text-muted-foreground" title={t(blob.activity)}>
|
||||||
{blob.activity}
|
{t(blob.activity)}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="block truncate text-muted-foreground/80">{label}</span>
|
<span className="block truncate text-muted-foreground/80">{label}</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex shrink-0 items-center gap-2 tabular-nums text-muted-foreground">
|
<span className="flex shrink-0 items-center gap-2 tabular-nums text-muted-foreground">
|
||||||
<span data-testid="activity-blob-cost">{blob.cost === null ? "—" : formatUsd(blob.cost)}</span>
|
<span data-testid="activity-blob-cost">{planCostLabel(blob.cost, subscriptionLabel(blob.model))}</span>
|
||||||
<span data-testid="activity-blob-elapsed">{formatElapsed(now - blob.startedAt)}</span>
|
<span data-testid="activity-blob-elapsed">{formatElapsed(now - blob.startedAt, language)}</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{/* The explicit END always on the blob's face (SCOPE) — the popover
|
{/* The explicit END always on the blob's face (SCOPE) — the popover
|
||||||
@@ -435,8 +442,8 @@ function ActivityBlobRow({
|
|||||||
data-testid="activity-abort"
|
data-testid="activity-abort"
|
||||||
onClick={onAbort}
|
onClick={onAbort}
|
||||||
disabled={abortState !== undefined}
|
disabled={abortState !== undefined}
|
||||||
title={abortState === "failed" ? "Abort failed — try again" : "End this background process"}
|
title={abortState === "failed" ? t("Abort failed — try again") : t("End this background process")}
|
||||||
aria-label={`End ${label} in ${blob.title}`}
|
aria-label={t("End {v0} in {v1}", { v0: label, v1: blob.title })}
|
||||||
className="absolute right-1 top-1/2 -translate-y-1/2 rounded-md p-1 text-muted-foreground/70 transition-colors hover:bg-destructive/10 hover:text-destructive disabled:opacity-50"
|
className="absolute right-1 top-1/2 -translate-y-1/2 rounded-md p-1 text-muted-foreground/70 transition-colors hover:bg-destructive/10 hover:text-destructive disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<SquareIcon className="size-3" />
|
<SquareIcon className="size-3" />
|
||||||
@@ -451,47 +458,46 @@ function ActivityBlobRow({
|
|||||||
<Icon className="size-3.5 text-muted-foreground" />
|
<Icon className="size-3.5 text-muted-foreground" />
|
||||||
<span className="font-medium">{label}</span>
|
<span className="font-medium">{label}</span>
|
||||||
<span className="ml-auto tabular-nums text-muted-foreground">
|
<span className="ml-auto tabular-nums text-muted-foreground">
|
||||||
{formatElapsed(now - blob.startedAt)}
|
{formatElapsed(now - blob.startedAt, language)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1 text-muted-foreground">
|
<div className="flex flex-col gap-1 text-muted-foreground">
|
||||||
<div className="truncate text-card-foreground" title={blob.title}>
|
<div className="truncate text-card-foreground" title={blob.title}>
|
||||||
{blob.title}
|
{blob.title}
|
||||||
</div>
|
</div>
|
||||||
{blob.activity && <div className="break-words">{blob.activity}</div>}
|
{blob.activity && <div className="break-words">{t(blob.activity)}</div>}
|
||||||
<div className="flex justify-between gap-2">
|
<div className="flex justify-between gap-2">
|
||||||
<span>Cost</span>
|
<span>{t("Cost")}</span>
|
||||||
<span className="tabular-nums text-card-foreground">
|
<span className="tabular-nums text-card-foreground">
|
||||||
{blob.cost === null ? "—" : formatUsd(blob.cost)}
|
{planCostLabel(blob.cost, subscriptionLabel(blob.model))}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between gap-2">
|
<div className="flex justify-between gap-2">
|
||||||
<span>Context</span>
|
<span>{t("Context")}</span>
|
||||||
<span className="tabular-nums text-card-foreground" data-testid="activity-blob-context">
|
<span className="tabular-nums text-card-foreground" data-testid="activity-blob-context">
|
||||||
{blob.context === null
|
{blob.context === null
|
||||||
? "—"
|
? "—"
|
||||||
: `${formatTokens(blob.context.used)} / ${formatTokens(blob.context.limit)}`}
|
: `${formatTokens(blob.context.used, language)} / ${formatTokens(blob.context.limit, language)}`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{blob.model && (
|
{blob.model && (
|
||||||
<div className="flex justify-between gap-2">
|
<div className="flex justify-between gap-2">
|
||||||
<span>Model</span>
|
<span>{t("Model")}</span>
|
||||||
<span className="truncate text-card-foreground">{blob.model}</span>
|
<span className="truncate text-card-foreground">{blob.model}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{blob.steps !== undefined && (
|
{blob.steps !== undefined && (
|
||||||
<div className="flex justify-between gap-2">
|
<div className="flex justify-between gap-2">
|
||||||
<span>Steps</span>
|
<span>{t("Steps")}</span>
|
||||||
<span className="tabular-nums text-card-foreground">{blob.steps}</span>
|
<span className="tabular-nums text-card-foreground">{blob.steps}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<a href={`/chat/${blob.chatId}`} className="text-primary hover:underline">
|
<a href={`/chat/${blob.chatId}`} className="text-primary hover:underline">
|
||||||
Open session →
|
{t("Open session →")}</a>
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 text-muted-foreground/80">
|
<div className="mt-2 text-muted-foreground/80">
|
||||||
{abortState === "failed" && "Abort failed — try again"}
|
{abortState === "failed" && t("Abort failed — try again")}
|
||||||
{abortState === "pending" && "Stopping…"}
|
{abortState === "pending" && t("Stopping…")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { useI18n } from "@/components/i18n";
|
||||||
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -31,6 +33,8 @@ import {
|
|||||||
import remarkBreaks from "remark-breaks";
|
import remarkBreaks from "remark-breaks";
|
||||||
import {
|
import {
|
||||||
Streamdown,
|
Streamdown,
|
||||||
|
defaultTranslations,
|
||||||
|
type StreamdownTranslations,
|
||||||
type PluginConfig,
|
type PluginConfig,
|
||||||
} from "streamdown";
|
} from "streamdown";
|
||||||
import { mdComponents } from "@/components/md-blocks";
|
import { mdComponents } from "@/components/md-blocks";
|
||||||
@@ -264,11 +268,12 @@ export const MessageBranchPrevious = ({
|
|||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: MessageBranchPreviousProps) => {
|
}: MessageBranchPreviousProps) => {
|
||||||
|
const { t } = useI18n();
|
||||||
const { goToPrevious, totalBranches } = useMessageBranch();
|
const { goToPrevious, totalBranches } = useMessageBranch();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
aria-label="Previous branch"
|
aria-label={t("Previous branch")}
|
||||||
disabled={totalBranches <= 1}
|
disabled={totalBranches <= 1}
|
||||||
onClick={goToPrevious}
|
onClick={goToPrevious}
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
@@ -287,11 +292,12 @@ export const MessageBranchNext = ({
|
|||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: MessageBranchNextProps) => {
|
}: MessageBranchNextProps) => {
|
||||||
|
const { t } = useI18n();
|
||||||
const { goToNext, totalBranches } = useMessageBranch();
|
const { goToNext, totalBranches } = useMessageBranch();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
aria-label="Next branch"
|
aria-label={t("Next branch")}
|
||||||
disabled={totalBranches <= 1}
|
disabled={totalBranches <= 1}
|
||||||
onClick={goToNext}
|
onClick={goToNext}
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
@@ -310,6 +316,7 @@ export const MessageBranchPage = ({
|
|||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: MessageBranchPageProps) => {
|
}: MessageBranchPageProps) => {
|
||||||
|
const { t } = useI18n();
|
||||||
const { currentBranch, totalBranches } = useMessageBranch();
|
const { currentBranch, totalBranches } = useMessageBranch();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -320,7 +327,7 @@ export const MessageBranchPage = ({
|
|||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{currentBranch + 1} of {totalBranches}
|
{currentBranch + 1}{" "}{t("of")}{" "}{totalBranches}
|
||||||
</ButtonGroupText>
|
</ButtonGroupText>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -357,6 +364,8 @@ export const streamdownPlugins = {
|
|||||||
|
|
||||||
export const MessageResponse = memo(
|
export const MessageResponse = memo(
|
||||||
({ className, children, ...props }: MessageResponseProps) => {
|
({ className, children, ...props }: MessageResponseProps) => {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const translations = Object.fromEntries(Object.entries(defaultTranslations).map(([key, text]) => [key, t(text)])) as Partial<StreamdownTranslations>;
|
||||||
// Legacy backslash delimiters are converted in a single linear scan;
|
// Legacy backslash delimiters are converted in a single linear scan;
|
||||||
// native dollar math passes through to Streamdown unchanged. Incomplete
|
// native dollar math passes through to Streamdown unchanged. Incomplete
|
||||||
// legacy spans are left for a later render when their closer arrives.
|
// legacy spans are left for a later render when their closer arrives.
|
||||||
@@ -373,6 +382,7 @@ export const MessageResponse = memo(
|
|||||||
"lh-prose size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
|
"lh-prose size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
translations={translations}
|
||||||
components={mdComponents}
|
components={mdComponents}
|
||||||
plugins={streamdownPlugins}
|
plugins={streamdownPlugins}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { useI18n } from "@/components/i18n";
|
||||||
|
|
||||||
import { useControllableState } from "@radix-ui/react-use-controllable-state";
|
import { useControllableState } from "@radix-ui/react-use-controllable-state";
|
||||||
import { blurAfterPointerClick } from "@/components/focus-hygiene";
|
import { blurAfterPointerClick } from "@/components/focus-hygiene";
|
||||||
@@ -134,20 +135,22 @@ export type ReasoningTriggerProps = ComponentProps<
|
|||||||
for a few seconds"), bookkeeping for information we knowingly lose. The
|
for a few seconds"), bookkeeping for information we knowingly lose. The
|
||||||
label pairs with the user's collapsed "Your thinking" rows — one
|
label pairs with the user's collapsed "Your thinking" rows — one
|
||||||
language; motion (spinner + shimmer) stays the working signal. */
|
language; motion (spinner + shimmer) stays the working signal. */
|
||||||
const defaultGetThinkingMessage = (isStreaming: boolean) => {
|
const DefaultThinkingMessage = ({ isStreaming }: { isStreaming: boolean }) => {
|
||||||
|
const { t } = useI18n();
|
||||||
if (isStreaming) {
|
if (isStreaming) {
|
||||||
return <Shimmer duration={1}>Thinking...</Shimmer>;
|
return <Shimmer duration={1}>{t("Thinking...")}</Shimmer>;
|
||||||
}
|
}
|
||||||
return <p>AGENT’S THINKING</p>;
|
return <p>{t("AGENT’S THINKING")}</p>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ReasoningTrigger = memo(
|
export const ReasoningTrigger = memo(
|
||||||
({
|
({
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
getThinkingMessage = defaultGetThinkingMessage,
|
getThinkingMessage = (isStreaming) => <DefaultThinkingMessage isStreaming={isStreaming} />,
|
||||||
...props
|
...props
|
||||||
}: ReasoningTriggerProps) => {
|
}: ReasoningTriggerProps) => {
|
||||||
|
const { t } = useI18n();
|
||||||
const { isStreaming, isOpen } = useReasoning();
|
const { isStreaming, isOpen } = useReasoning();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -175,7 +178,7 @@ export const ReasoningTrigger = memo(
|
|||||||
the component default (h-3) — IDENTICAL to the placeholder
|
the component default (h-3) — IDENTICAL to the placeholder
|
||||||
spinner (owner, 2026-10-02): the handoff between the two
|
spinner (owner, 2026-10-02): the handoff between the two
|
||||||
states must be visually seamless, one size everywhere. */}
|
states must be visually seamless, one size everywhere. */}
|
||||||
{isStreaming && <BrailleSpinner label="Thinking" />}
|
{isStreaming && <BrailleSpinner label={t("Thinking")} />}
|
||||||
{getThinkingMessage(isStreaming)}
|
{getThinkingMessage(isStreaming)}
|
||||||
<ChevronDownIcon
|
<ChevronDownIcon
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { useI18n } from "@/components/i18n";
|
||||||
|
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { ConfidenceRail } from "@/components/confidence-rail";
|
import { ConfidenceRail } from "@/components/confidence-rail";
|
||||||
@@ -20,12 +22,12 @@ import { parseAskInput, parseQuizInput } from "@/src/core";
|
|||||||
* the explanation, with NO verdict chips (owner ruling 2026-09-27).
|
* the explanation, with NO verdict chips (owner ruling 2026-09-27).
|
||||||
*/
|
*/
|
||||||
export function AnsweredCard({ answer, input }: { answer: QuestionAnswerData; input?: unknown }) {
|
export function AnsweredCard({ answer, input }: { answer: QuestionAnswerData; input?: unknown }) {
|
||||||
|
const { t } = useI18n();
|
||||||
const [whyOpen, setWhyOpen] = useState(false); // the tucked explanation (predictions, owner 2026-10-03)
|
const [whyOpen, setWhyOpen] = useState(false); // the tucked explanation (predictions, owner 2026-10-03)
|
||||||
if (answer.dismissed) {
|
if (answer.dismissed) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border bg-card p-4 text-sm text-muted-foreground">
|
<div className="rounded-xl border bg-card p-4 text-sm text-muted-foreground">
|
||||||
<span className="font-medium">{answer.questionText}</span> — skipped
|
<span className="font-medium">{answer.questionText}</span> {" "}{t("— skipped")}</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,7 +53,7 @@ export function AnsweredCard({ answer, input }: { answer: QuestionAnswerData; in
|
|||||||
ThinkingBlock, instead of an inset inside the answer. Same muted
|
ThinkingBlock, instead of an inset inside the answer. Same muted
|
||||||
rail as before; the label is now plain "Your thinking". */}
|
rail as before; the label is now plain "Your thinking". */}
|
||||||
{answer.readingThinking && (
|
{answer.readingThinking && (
|
||||||
<ThinkingBlock testId="reading-thinking-block" label="YOUR THINKING" text={answer.readingThinking} defaultOpen />
|
<ThinkingBlock testId="reading-thinking-block" label={t("YOUR THINKING")} text={answer.readingThinking} defaultOpen />
|
||||||
)}
|
)}
|
||||||
{/* [&>*:last-child]:mb-0 (owner round, 2026-10-07): on ask cards the
|
{/* [&>*:last-child]:mb-0 (owner round, 2026-10-07): on ask cards the
|
||||||
thinking row is the last child — its my-3 stacked with the card's
|
thinking row is the last child — its my-3 stacked with the card's
|
||||||
@@ -66,23 +68,23 @@ export function AnsweredCard({ answer, input }: { answer: QuestionAnswerData; in
|
|||||||
cards label Probe/Recall — the phase boundary visible in the
|
cards label Probe/Recall — the phase boundary visible in the
|
||||||
transcript. No `tool` on old records: kind labels as before. */}
|
transcript. No `tool` on old records: kind labels as before. */}
|
||||||
{answer.tool === "probe"
|
{answer.tool === "probe"
|
||||||
? "Probe"
|
? t("Probe")
|
||||||
: answer.tool === "recall"
|
: answer.tool === "recall"
|
||||||
? "Recall"
|
? t("Recall")
|
||||||
: isQuiz
|
: isQuiz
|
||||||
? answer.questionType === "predict"
|
? answer.questionType === "predict"
|
||||||
? "Prediction"
|
? t("Prediction")
|
||||||
: "Check"
|
: t("Check")
|
||||||
: isExplain
|
: isExplain
|
||||||
? "Explain"
|
? t("Explain")
|
||||||
: "Question"}
|
: t("Question")}
|
||||||
{isQuiz &&
|
{isQuiz &&
|
||||||
(answer.dontKnow ? (
|
(answer.dontKnow ? (
|
||||||
<span className="rounded bg-muted px-1.5 py-0.5">didn't know</span>
|
<span className="rounded bg-muted px-1.5 py-0.5">{t("didn't know")}</span>
|
||||||
) : answer.correct ? (
|
) : answer.correct ? (
|
||||||
<span className="rounded bg-green-500/15 px-1.5 py-0.5 text-green-500">correct</span>
|
<span className="rounded bg-green-500/15 px-1.5 py-0.5 text-green-500">{t("correct")}</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="rounded bg-red-500/15 px-1.5 py-0.5 text-red-500">incorrect</span>
|
<span className="rounded bg-red-500/15 px-1.5 py-0.5 text-red-500">{t("incorrect")}</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-3 font-medium">
|
<div className="mb-3 font-medium">
|
||||||
@@ -176,7 +178,7 @@ export function AnsweredCard({ answer, input }: { answer: QuestionAnswerData; in
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{answer.dontKnow && isQuiz && (
|
{answer.dontKnow && isQuiz && (
|
||||||
<div className="mt-2 text-sm text-muted-foreground">Answered “I don't know”.</div>
|
<div className="mt-2 text-sm text-muted-foreground">{t("Answered “I don't know”.")}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(answer.thinking ?? answer.note) && (
|
{(answer.thinking ?? answer.note) && (
|
||||||
@@ -186,7 +188,7 @@ export function AnsweredCard({ answer, input }: { answer: QuestionAnswerData; in
|
|||||||
the accent lift read too light); rail only, heights identical. */
|
the accent lift read too light); rail only, heights identical. */
|
||||||
<ThinkingBlock
|
<ThinkingBlock
|
||||||
testId="your-thinking"
|
testId="your-thinking"
|
||||||
label="YOUR THINKING"
|
label={t("YOUR THINKING")}
|
||||||
text={answer.thinking ?? answer.note ?? ""}
|
text={answer.thinking ?? answer.note ?? ""}
|
||||||
inCard
|
inCard
|
||||||
/>
|
/>
|
||||||
@@ -206,11 +208,11 @@ export function AnsweredCard({ answer, input }: { answer: QuestionAnswerData; in
|
|||||||
onClick={() => setWhyOpen((v) => !v)}
|
onClick={() => setWhyOpen((v) => !v)}
|
||||||
className="text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
|
className="text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
|
||||||
>
|
>
|
||||||
{whyOpen ? "Hide why" : "Why"}
|
{whyOpen ? t("Hide why") : t("Why")}
|
||||||
</button>
|
</button>
|
||||||
{whyOpen && (
|
{whyOpen && (
|
||||||
<div className="mt-1.5 rounded-lg bg-muted py-2 text-sm text-foreground/50">
|
<div className="mt-1.5 rounded-lg bg-muted py-2 text-sm text-foreground/50">
|
||||||
<span className="font-medium text-muted-foreground">Why: </span>
|
<span className="font-medium text-muted-foreground">{t("Why:")}{" "}</span>
|
||||||
{/* The markdown wrapper must go truly inline — a block <p> after the
|
{/* The markdown wrapper must go truly inline — a block <p> after the
|
||||||
"Why:" label created the oversized leading gap (owner's fix list). */}
|
"Why:" label created the oversized leading gap (owner's fix list). */}
|
||||||
<MessageResponse className="inline [&_p]:inline [&_p]:m-0">{answer.explanation}</MessageResponse>
|
<MessageResponse className="inline [&_p]:inline [&_p]:m-0">{answer.explanation}</MessageResponse>
|
||||||
@@ -224,7 +226,7 @@ export function AnsweredCard({ answer, input }: { answer: QuestionAnswerData; in
|
|||||||
the model's next reply IS the grading. */}
|
the model's next reply IS the grading. */}
|
||||||
{isExplain && answer.committed && (
|
{isExplain && answer.committed && (
|
||||||
<div className="mt-3 rounded-lg bg-muted py-2 text-sm text-muted-foreground">
|
<div className="mt-3 rounded-lg bg-muted py-2 text-sm text-muted-foreground">
|
||||||
<span className="font-medium text-foreground">The answer: </span>
|
<span className="font-medium text-foreground">{t("The answer:")}{" "}</span>
|
||||||
<MessageResponse className="inline [&_p]:inline [&_p]:m-0">{answer.committed}</MessageResponse>
|
<MessageResponse className="inline [&_p]:inline [&_p]:m-0">{answer.committed}</MessageResponse>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { useI18n } from "@/components/i18n";
|
||||||
|
|
||||||
|
|
||||||
import { useContext, useEffect, useMemo, useRef, useState } from "react";
|
import { useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { DownloadIcon } from "lucide-react";
|
import { DownloadIcon } from "lucide-react";
|
||||||
@@ -100,12 +102,12 @@ export function DemoOutputCard({
|
|||||||
messageId?: string;
|
messageId?: string;
|
||||||
initialInverted?: boolean;
|
initialInverted?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
const result = output as DemoResult | undefined;
|
const result = output as DemoResult | undefined;
|
||||||
if (!result || typeof result.html !== "string" || typeof result.title !== "string") {
|
if (!result || typeof result.html !== "string" || typeof result.title !== "string") {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border bg-card p-4 text-sm text-muted-foreground">
|
<div className="rounded-xl border bg-card p-4 text-sm text-muted-foreground">
|
||||||
Demo completed, but the result could not be displayed.
|
{t("Demo completed, but the result could not be displayed.")}</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const failed = result.html.length === 0;
|
const failed = result.html.length === 0;
|
||||||
@@ -113,10 +115,9 @@ export function DemoOutputCard({
|
|||||||
return (
|
return (
|
||||||
<div className="rounded-xl border bg-card p-4 text-card-foreground" data-testid="demo-card-failed">
|
<div className="rounded-xl border bg-card p-4 text-card-foreground" data-testid="demo-card-failed">
|
||||||
<div className="flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
<div className="flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
Interactive demo
|
{t("Interactive demo")}<span className="rounded bg-red-500/15 px-1.5 py-0.5 text-red-500">{t("failed")}</span>
|
||||||
<span className="rounded bg-red-500/15 px-1.5 py-0.5 text-red-500">failed</span>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-sm text-muted-foreground">{result.notes ?? "The demo could not be built."}</p>
|
<p className="mt-2 text-sm text-muted-foreground">{result.notes ?? t("The demo could not be built.")}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -136,6 +137,7 @@ function DemoFrame({
|
|||||||
messageId?: string;
|
messageId?: string;
|
||||||
initialInverted?: boolean;
|
initialInverted?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
const actions = useContext(ChatActionsContext);
|
const actions = useContext(ChatActionsContext);
|
||||||
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||||
const [state, setState] = useState<DemoBridgeState>(INITIAL_DEMO_BRIDGE_STATE);
|
const [state, setState] = useState<DemoBridgeState>(INITIAL_DEMO_BRIDGE_STATE);
|
||||||
@@ -177,22 +179,21 @@ function DemoFrame({
|
|||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div className="flex min-w-0 items-baseline gap-2">
|
<div className="flex min-w-0 items-baseline gap-2">
|
||||||
<span className="shrink-0 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
<span className="shrink-0 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
interactive demo
|
{t("interactive demo")}</span>
|
||||||
</span>
|
|
||||||
<span className="truncate text-xs font-medium tracking-wide text-foreground">{title}</span>
|
<span className="truncate text-xs font-medium tracking-wide text-foreground">{title}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-1.5">
|
<div className="flex shrink-0 items-center gap-1.5">
|
||||||
<Toggle
|
<Toggle
|
||||||
checked={inverted}
|
checked={inverted}
|
||||||
onChange={toggleInverted}
|
onChange={toggleInverted}
|
||||||
label="Invert demo brightness"
|
label={t("Invert demo brightness")}
|
||||||
title="Invert colors"
|
title={t("Invert colors")}
|
||||||
discIcon={<InvertDiscIcon className="size-3" />}
|
discIcon={<InvertDiscIcon className="size-3" />}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Download demo as HTML"
|
aria-label={t("Download demo as HTML")}
|
||||||
title="Download as HTML"
|
title={t("Download as HTML")}
|
||||||
onClick={() => downloadFile(`${slugFilename(title)}.html`, html, "text/html")}
|
onClick={() => downloadFile(`${slugFilename(title)}.html`, html, "text/html")}
|
||||||
data-testid="demo-download"
|
data-testid="demo-download"
|
||||||
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||||
@@ -205,7 +206,7 @@ function DemoFrame({
|
|||||||
<DemoErrorChip
|
<DemoErrorChip
|
||||||
title={title}
|
title={title}
|
||||||
message={state.error}
|
message={state.error}
|
||||||
onSend={actions ? () => actions.sendText(`The demo "${title}" threw an error: ${state.error}`) : undefined}
|
onSend={actions ? () => actions.sendText(t("The demo \"{v0}\" threw an error: {v1}", { v0: title, v1: state.error })) : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<iframe
|
<iframe
|
||||||
@@ -245,12 +246,13 @@ export function DemoErrorChip({
|
|||||||
message: string;
|
message: string;
|
||||||
onSend?: () => void;
|
onSend?: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="mt-2 flex flex-wrap items-center gap-2 rounded-lg bg-red-500/10 px-3 py-2 text-sm text-red-500"
|
className="mt-2 flex flex-wrap items-center gap-2 rounded-lg bg-red-500/10 px-3 py-2 text-sm text-red-500"
|
||||||
data-testid="demo-error-chip"
|
data-testid="demo-error-chip"
|
||||||
>
|
>
|
||||||
<span className="font-medium">This demo threw an error</span>
|
<span className="font-medium">{t("This demo threw an error")}</span>
|
||||||
<span className="min-w-0 flex-1 truncate font-mono text-xs" title={message}>
|
<span className="min-w-0 flex-1 truncate font-mono text-xs" title={message}>
|
||||||
{message}
|
{message}
|
||||||
</span>
|
</span>
|
||||||
@@ -261,8 +263,7 @@ export function DemoErrorChip({
|
|||||||
className="rounded-md border border-red-500/40 px-2 py-1 text-xs font-medium hover:bg-red-500/15"
|
className="rounded-md border border-red-500/40 px-2 py-1 text-xs font-medium hover:bg-red-500/15"
|
||||||
data-testid="demo-error-send"
|
data-testid="demo-error-send"
|
||||||
>
|
>
|
||||||
Send to teacher
|
{t("Send to teacher")}</button>
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { useI18n } from "@/components/i18n";
|
||||||
|
|
||||||
|
|
||||||
import { useContext } from "react";
|
import { useContext } from "react";
|
||||||
import { CheckIcon, SettingsIcon, XIcon } from "lucide-react";
|
import { CheckIcon, SettingsIcon, XIcon } from "lucide-react";
|
||||||
@@ -27,12 +29,13 @@ const KIND_LABEL: Record<JobKind, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function DispatchCard({ toolCallId, kind }: { toolCallId: string; kind: JobKind }) {
|
export function DispatchCard({ toolCallId, kind }: { toolCallId: string; kind: JobKind }) {
|
||||||
|
const { t } = useI18n();
|
||||||
const jobs = useContext(JobsContext);
|
const jobs = useContext(JobsContext);
|
||||||
const job = jobs.find((j) => j.toolCallId === toolCallId) ?? null;
|
const job = jobs.find((j) => j.toolCallId === toolCallId) ?? null;
|
||||||
const label = KIND_LABEL[kind];
|
const label = KIND_LABEL[kind];
|
||||||
|
|
||||||
if (!job || job.status === "running") {
|
if (!job || job.status === "running") {
|
||||||
return <DispatchActivityCard job={job} label={label} />;
|
return <DispatchActivityCard job={job} label={t(label)} />;
|
||||||
}
|
}
|
||||||
// BYOK (owner, 2026-09-22): a job refused for missing models is INFO,
|
// BYOK (owner, 2026-09-22): a job refused for missing models is INFO,
|
||||||
// not scary-error — gray glyph + "needs model setup" + the deep-link
|
// not scary-error — gray glyph + "needs model setup" + the deep-link
|
||||||
@@ -41,10 +44,10 @@ export function DispatchCard({ toolCallId, kind }: { toolCallId: string; kind: J
|
|||||||
// whose key is gone reads as the key being needed — the deep-link
|
// whose key is gone reads as the key being needed — the deep-link
|
||||||
// lands on the SetupDialog's first-key stage on its own.
|
// lands on the SetupDialog's first-key stage on its own.
|
||||||
if (isModelsRefusal(job.error)) {
|
if (isModelsRefusal(job.error)) {
|
||||||
return <DispatchRefusedCard label={label} line="needs model setup" button="Set up models" />;
|
return <DispatchRefusedCard label={t(label)} line={t("needs model setup")} button={t("Set up models")} />;
|
||||||
}
|
}
|
||||||
if (isProviderKeyRefusal(job.error)) {
|
if (isProviderKeyRefusal(job.error)) {
|
||||||
return <DispatchRefusedCard label={label} line="needs a provider key" button="Add a key" />;
|
return <DispatchRefusedCard label={t(label)} line={t("needs a provider key")} button={t("Add a key")} />;
|
||||||
}
|
}
|
||||||
const failed = job.status === "error";
|
const failed = job.status === "error";
|
||||||
return (
|
return (
|
||||||
@@ -53,14 +56,15 @@ export function DispatchCard({ toolCallId, kind }: { toolCallId: string; kind: J
|
|||||||
data-testid="dispatch-settled"
|
data-testid="dispatch-settled"
|
||||||
>
|
>
|
||||||
{failed ? <XIcon className="size-3.5 text-red-500" /> : <CheckIcon className="size-3.5" />}
|
{failed ? <XIcon className="size-3.5 text-red-500" /> : <CheckIcon className="size-3.5" />}
|
||||||
<span className="font-medium uppercase tracking-wide">{label}</span>
|
<span className="font-medium uppercase tracking-wide">{t(label)}</span>
|
||||||
<span>{failed ? "failed" : "delivered"}</span>
|
<span>{failed ? t("failed") : t("delivered")}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The refused card: quiet info tone — nothing red, nothing "failed". */
|
/** The refused card: quiet info tone — nothing red, nothing "failed". */
|
||||||
function DispatchRefusedCard({ label, line, button }: { label: string; line: string; button: string }) {
|
function DispatchRefusedCard({ label, line, button }: { label: string; line: string; button: string }) {
|
||||||
|
const { t } = useI18n();
|
||||||
const ctx = useContext(ChatActionsContext);
|
const ctx = useContext(ChatActionsContext);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -68,7 +72,7 @@ function DispatchRefusedCard({ label, line, button }: { label: string; line: str
|
|||||||
data-testid="dispatch-refused"
|
data-testid="dispatch-refused"
|
||||||
>
|
>
|
||||||
<SettingsIcon className="size-3.5" />
|
<SettingsIcon className="size-3.5" />
|
||||||
<span className="font-medium uppercase tracking-wide">{label}</span>
|
<span className="font-medium uppercase tracking-wide">{t(label)}</span>
|
||||||
<span>{line}</span>
|
<span>{line}</span>
|
||||||
{ctx && (
|
{ctx && (
|
||||||
<button
|
<button
|
||||||
@@ -85,6 +89,7 @@ function DispatchRefusedCard({ label, line, button }: { label: string; line: str
|
|||||||
}
|
}
|
||||||
|
|
||||||
function DispatchActivityCard({ job, label }: { job: JobRecord | null; label: string }) {
|
function DispatchActivityCard({ job, label }: { job: JobRecord | null; label: string }) {
|
||||||
|
const { t } = useI18n();
|
||||||
// Elapsed from createdAt — jobs appear here only via the jobs feed (post-mount),
|
// Elapsed from createdAt — jobs appear here only via the jobs feed (post-mount),
|
||||||
// so no SSR/hydration mismatch.
|
// so no SSR/hydration mismatch.
|
||||||
const elapsed =
|
const elapsed =
|
||||||
@@ -99,19 +104,18 @@ function DispatchActivityCard({ job, label }: { job: JobRecord | null; label: st
|
|||||||
the Braille spinner replaces the pulsing dot — one indicator
|
the Braille spinner replaces the pulsing dot — one indicator
|
||||||
everywhere. This is the ONLY dispatch-card change; the card's
|
everywhere. This is the ONLY dispatch-card change; the card's
|
||||||
shape is untouched. */}
|
shape is untouched. */}
|
||||||
<BrailleSpinner label={`${label} running`} />
|
<BrailleSpinner label={t("{v0} running", { v0: label })} />
|
||||||
{label}
|
{t(label)}
|
||||||
{elapsed !== null && <span className="font-normal">· {elapsed}s</span>}
|
{elapsed !== null && <span className="font-normal">· {t("{v0}s", { v0: elapsed })}</span>}
|
||||||
</div>
|
</div>
|
||||||
{job?.activity && (
|
{job?.activity && (
|
||||||
<div className="mt-1 truncate text-sm text-muted-foreground" data-testid="dispatch-activity-line">
|
<div className="mt-1 truncate text-sm text-muted-foreground" data-testid="dispatch-activity-line">
|
||||||
{job.activity}
|
{t(job.activity)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!job && (
|
{!job && (
|
||||||
<div className="mt-1 text-sm text-muted-foreground" data-testid="dispatch-pending">
|
<div className="mt-1 text-sm text-muted-foreground" data-testid="dispatch-pending">
|
||||||
Dispatched…
|
{t("Dispatched…")}</div>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export function slugFilename(title: string): string {
|
|||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.normalize("NFKD")
|
.normalize("NFKD")
|
||||||
.replace(/[\u0300-\u036f]/g, "")
|
.replace(/[\u0300-\u036f]/g, "")
|
||||||
.replace(/[^a-z0-9]+/g, "-")
|
.replace(/[^\p{L}\p{N}]+/gu, "-")
|
||||||
.replace(/^-+|-+$/g, "")
|
.replace(/^-+|-+$/g, "")
|
||||||
.slice(0, 64);
|
.slice(0, 64);
|
||||||
return slug || "demo";
|
return slug || "demo";
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { useI18n } from "@/components/i18n";
|
||||||
|
|
||||||
|
|
||||||
import { DispatchCard } from "@/components/artifacts/dispatch-card";
|
import { DispatchCard } from "@/components/artifacts/dispatch-card";
|
||||||
import { MessageResponse } from "@/components/ai-elements/message";
|
import { MessageResponse } from "@/components/ai-elements/message";
|
||||||
@@ -35,15 +37,15 @@ export function PdfJobCard({ part }: ArtifactPartProps) {
|
|||||||
* the same expand-on-demand idiom as research.
|
* the same expand-on-demand idiom as research.
|
||||||
*/
|
*/
|
||||||
export function PdfBriefCard({ output }: { output: unknown }) {
|
export function PdfBriefCard({ output }: { output: unknown }) {
|
||||||
|
const { t } = useI18n();
|
||||||
const result = output as PdfResult | undefined;
|
const result = output as PdfResult | undefined;
|
||||||
if (!result || typeof result.brief !== "string") {
|
if (!result || typeof result.brief !== "string") {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border bg-card p-4 text-sm text-muted-foreground">
|
<div className="rounded-xl border bg-card p-4 text-sm text-muted-foreground">
|
||||||
PDF reading completed, but the result could not be displayed.
|
{t("PDF reading completed, but the result could not be displayed.")}</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const failed = result.brief.startsWith("PDF failed");
|
const failed = result.brief.startsWith(t("PDF failed"));
|
||||||
// Read = extract/render output entered the loop; consulted = every page
|
// Read = extract/render output entered the loop; consulted = every page
|
||||||
// incl. search hits (the full audit set — the brief's citations may point
|
// incl. search hits (the full audit set — the brief's citations may point
|
||||||
// there). The ?? fallbacks tolerate pre-split persisted records.
|
// there). The ?? fallbacks tolerate pre-split persisted records.
|
||||||
@@ -52,20 +54,19 @@ export function PdfBriefCard({ output }: { output: unknown }) {
|
|||||||
return (
|
return (
|
||||||
<div className="rounded-xl border bg-card p-4 text-card-foreground" data-testid="pdf-brief-card">
|
<div className="rounded-xl border bg-card p-4 text-card-foreground" data-testid="pdf-brief-card">
|
||||||
<div className="mb-1 flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
<div className="mb-1 flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
PDF brief
|
{t("PDF brief")}{failed && <span className="rounded bg-red-500/15 px-1.5 py-0.5 text-red-500">{t("failed")}</span>}
|
||||||
{failed && <span className="rounded bg-red-500/15 px-1.5 py-0.5 text-red-500">failed</span>}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm [&_p:first-child]:mt-0 [&_p:last-child]:mb-0">
|
<div className="text-sm [&_p:first-child]:mt-0 [&_p:last-child]:mb-0">
|
||||||
<MessageResponse>{result.brief}</MessageResponse>
|
<MessageResponse>{result.brief}</MessageResponse>
|
||||||
</div>
|
</div>
|
||||||
{!failed && readList.length > 0 && (
|
{!failed && readList.length > 0 && (
|
||||||
<div className="mt-3 border-t pt-2 text-xs text-muted-foreground">
|
<div className="mt-3 border-t pt-2 text-xs text-muted-foreground">
|
||||||
Pages read: {readList.join(", ")}
|
{t("Pages read:")}{" "}{readList.join(", ")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!failed && consultedList.length > readList.length && (
|
{!failed && consultedList.length > readList.length && (
|
||||||
<div className="mt-3 border-t pt-2 text-xs text-muted-foreground">
|
<div className="mt-3 border-t pt-2 text-xs text-muted-foreground">
|
||||||
Pages consulted: {consultedList.join(", ")}
|
{t("Pages consulted:")}{" "}{consultedList.join(", ")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -77,27 +78,26 @@ export function PdfBriefCard({ output }: { output: unknown }) {
|
|||||||
* the steer card (sub-agent result delivery) renders the same artifact.
|
* the steer card (sub-agent result delivery) renders the same artifact.
|
||||||
*/
|
*/
|
||||||
export function ResearchBriefCard({ output }: { output: unknown }) {
|
export function ResearchBriefCard({ output }: { output: unknown }) {
|
||||||
|
const { t } = useI18n();
|
||||||
const result = output as ResearchResult | undefined;
|
const result = output as ResearchResult | undefined;
|
||||||
if (!result || typeof result.brief !== "string") {
|
if (!result || typeof result.brief !== "string") {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border bg-card p-4 text-sm text-muted-foreground">
|
<div className="rounded-xl border bg-card p-4 text-sm text-muted-foreground">
|
||||||
Research completed, but the result could not be displayed.
|
{t("Research completed, but the result could not be displayed.")}</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const failed = result.brief.startsWith("Research failed");
|
const failed = result.brief.startsWith(t("Research failed"));
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border bg-card p-4 text-card-foreground" data-testid="research-brief-card">
|
<div className="rounded-xl border bg-card p-4 text-card-foreground" data-testid="research-brief-card">
|
||||||
<div className="mb-1 flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
<div className="mb-1 flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
Research brief
|
{t("Research brief")}{failed && <span className="rounded bg-red-500/15 px-1.5 py-0.5 text-red-500">{t("failed")}</span>}
|
||||||
{failed && <span className="rounded bg-red-500/15 px-1.5 py-0.5 text-red-500">failed</span>}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm [&_p:first-child]:mt-0 [&_p:last-child]:mb-0">
|
<div className="text-sm [&_p:first-child]:mt-0 [&_p:last-child]:mb-0">
|
||||||
<MessageResponse>{result.brief}</MessageResponse>
|
<MessageResponse>{result.brief}</MessageResponse>
|
||||||
</div>
|
</div>
|
||||||
{result.sources.length > 0 && (
|
{result.sources.length > 0 && (
|
||||||
<div className="mt-3 border-t pt-2">
|
<div className="mt-3 border-t pt-2">
|
||||||
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">Sources</div>
|
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">{t("Sources")}</div>
|
||||||
<ul className="flex flex-col gap-0.5 text-xs">
|
<ul className="flex flex-col gap-0.5 text-xs">
|
||||||
{result.sources.map((s) => (
|
{result.sources.map((s) => (
|
||||||
<li key={s.url} className="truncate">
|
<li key={s.url} className="truncate">
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { useI18n } from "@/components/i18n";
|
||||||
|
|
||||||
|
|
||||||
import { ChevronRight } from "lucide-react";
|
import { ChevronRight } from "lucide-react";
|
||||||
import { BrailleSpinner } from "@/components/braille-spinner";
|
import { BrailleSpinner } from "@/components/braille-spinner";
|
||||||
@@ -28,6 +30,7 @@ interface StreamingToolPart {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function LiveToolCard({ part }: ArtifactPartProps) {
|
export function LiveToolCard({ part }: ArtifactPartProps) {
|
||||||
|
const { t } = useI18n();
|
||||||
const p = part as StreamingToolPart;
|
const p = part as StreamingToolPart;
|
||||||
const tool = p.type.replace(/^tool-/, "");
|
const tool = p.type.replace(/^tool-/, "");
|
||||||
const inp = (p.input ?? {}) as Record<string, unknown>;
|
const inp = (p.input ?? {}) as Record<string, unknown>;
|
||||||
@@ -47,7 +50,7 @@ export function LiveToolCard({ part }: ArtifactPartProps) {
|
|||||||
className="group my-1 w-full max-w-2xl select-none"
|
className="group my-1 w-full max-w-2xl select-none"
|
||||||
>
|
>
|
||||||
<summary className="flex cursor-pointer list-none items-center gap-2 text-xs text-muted-foreground [&::-webkit-details-marker]:hidden">
|
<summary className="flex cursor-pointer list-none items-center gap-2 text-xs text-muted-foreground [&::-webkit-details-marker]:hidden">
|
||||||
<BrailleSpinner label={`${toolActivityLabel(tool)} — live`} />
|
<BrailleSpinner label={t("{v0} — live", { v0: toolActivityLabel(tool) })} />
|
||||||
<Shimmer duration={1} className="text-xs">
|
<Shimmer duration={1} className="text-xs">
|
||||||
{toolActivityLabel(tool)}
|
{toolActivityLabel(tool)}
|
||||||
</Shimmer>
|
</Shimmer>
|
||||||
@@ -55,7 +58,7 @@ export function LiveToolCard({ part }: ArtifactPartProps) {
|
|||||||
<ChevronRight className="size-3 shrink-0 opacity-50 transition-transform group-open:rotate-90" />
|
<ChevronRight className="size-3 shrink-0 opacity-50 transition-transform group-open:rotate-90" />
|
||||||
</summary>
|
</summary>
|
||||||
<div className="mt-2 rounded-xl border bg-card p-3 text-xs">
|
<div className="mt-2 rounded-xl border bg-card p-3 text-xs">
|
||||||
<div className="mb-1 font-medium uppercase tracking-wide text-muted-foreground">Input (streaming)</div>
|
<div className="mb-1 font-medium uppercase tracking-wide text-muted-foreground">{t("Input (streaming)")}</div>
|
||||||
<pre className="max-h-32 overflow-auto whitespace-pre-wrap break-all font-mono text-[11px] leading-relaxed">
|
<pre className="max-h-32 overflow-auto whitespace-pre-wrap break-all font-mono text-[11px] leading-relaxed">
|
||||||
{hasInput ? JSON.stringify(p.input, null, 2) : "…"}
|
{hasInput ? JSON.stringify(p.input, null, 2) : "…"}
|
||||||
</pre>
|
</pre>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { useI18n } from "@/components/i18n";
|
||||||
|
|
||||||
|
|
||||||
import { ClipboardCheckIcon } from "lucide-react";
|
import { ClipboardCheckIcon } from "lucide-react";
|
||||||
import { parseProbeDoneInput, type ProbeDoneResult } from "@/src/core";
|
import { parseProbeDoneInput, type ProbeDoneResult } from "@/src/core";
|
||||||
@@ -22,6 +24,7 @@ import { parseProbeDoneInput, type ProbeDoneResult } from "@/src/core";
|
|||||||
* the UI wave; this card just guarantees the data is never invisible.
|
* the UI wave; this card just guarantees the data is never invisible.
|
||||||
*/
|
*/
|
||||||
export function ProbeDoneCard({ part }: { part: { input?: unknown; output?: unknown; state?: unknown } }) {
|
export function ProbeDoneCard({ part }: { part: { input?: unknown; output?: unknown; state?: unknown } }) {
|
||||||
|
const { t } = useI18n();
|
||||||
const output = part.output as ProbeDoneResult | undefined;
|
const output = part.output as ProbeDoneResult | undefined;
|
||||||
const state = typeof part.state === "string" ? part.state : "";
|
const state = typeof part.state === "string" ? part.state : "";
|
||||||
|
|
||||||
@@ -43,14 +46,12 @@ export function ProbeDoneCard({ part }: { part: { input?: unknown; output?: unkn
|
|||||||
<div data-testid="probe-done-card" className="my-3 rounded-xl border bg-card/60 p-3">
|
<div data-testid="probe-done-card" className="my-3 rounded-xl border bg-card/60 p-3">
|
||||||
<div className="mb-2 flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
<div className="mb-2 flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
<ClipboardCheckIcon className="size-3.5 text-primary" />
|
<ClipboardCheckIcon className="size-3.5 text-primary" />
|
||||||
Probe concluded — your assessment
|
{t("Probe concluded — your assessment")}</div>
|
||||||
</div>
|
|
||||||
{output && !output.ok ? (
|
{output && !output.ok ? (
|
||||||
<p className="text-sm text-muted-foreground">The prober retried: {output.error}</p>
|
<p className="text-sm text-muted-foreground">{t("The prober retried:")}{" "}{output.error}</p>
|
||||||
) : state === "output-error" ? (
|
) : state === "output-error" ? (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
The assessment was malformed — the prober retried with a corrected one.
|
{t("The assessment was malformed — the prober retried with a corrected one.")}</p>
|
||||||
</p>
|
|
||||||
) : strands ? (
|
) : strands ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{strands.map((s, i) => (
|
{strands.map((s, i) => (
|
||||||
@@ -64,7 +65,7 @@ export function ProbeDoneCard({ part }: { part: { input?: unknown; output?: unkn
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted-foreground">Writing the assessment…</p>
|
<p className="text-sm text-muted-foreground">{t("Writing the assessment…")}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { useI18n } from "@/components/i18n";
|
||||||
|
|
||||||
|
|
||||||
import { CheckIcon, CircleX } from "lucide-react";
|
import { CheckIcon, CircleX } from "lucide-react";
|
||||||
import type { PdfResult, ResearchResult, SubagentArtifactData } from "@/src/core";
|
import type { PdfResult, ResearchResult, SubagentArtifactData } from "@/src/core";
|
||||||
@@ -26,6 +28,7 @@ import { VisualOutputCard } from "@/components/artifacts/visual-card";
|
|||||||
* render; only a failure note does.
|
* render; only a failure note does.
|
||||||
*/
|
*/
|
||||||
export function SubagentSteerCard({ message }: { message: LhViewMessage }) {
|
export function SubagentSteerCard({ message }: { message: LhViewMessage }) {
|
||||||
|
const { t } = useI18n();
|
||||||
const artifactPart = message.parts.find((p) => p.type === "data-subagent-artifact");
|
const artifactPart = message.parts.find((p) => p.type === "data-subagent-artifact");
|
||||||
const data = artifactPart?.data as SubagentArtifactData | undefined;
|
const data = artifactPart?.data as SubagentArtifactData | undefined;
|
||||||
// The abort steer (2026-09 dock semantics): its message-type part is
|
// The abort steer (2026-09 dock semantics): its message-type part is
|
||||||
@@ -38,8 +41,7 @@ export function SubagentSteerCard({ message }: { message: LhViewMessage }) {
|
|||||||
if (aborted?.data) return <AbortedBadge kind={aborted.data.kind ?? "job"} />;
|
if (aborted?.data) return <AbortedBadge kind={aborted.data.kind ?? "job"} />;
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border bg-card p-4 text-sm text-muted-foreground">
|
<div className="rounded-xl border bg-card p-4 text-sm text-muted-foreground">
|
||||||
A sub-agent finished, but its result could not be displayed.
|
{t("A sub-agent finished, but its result could not be displayed.")}</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
switch (data.kind) {
|
switch (data.kind) {
|
||||||
@@ -66,11 +68,12 @@ export function SubagentSteerCard({ message }: { message: LhViewMessage }) {
|
|||||||
* teacher's wake turn acknowledges the abort in the lesson itself,
|
* teacher's wake turn acknowledges the abort in the lesson itself,
|
||||||
* this just marks what happened. */
|
* this just marks what happened. */
|
||||||
function AbortedBadge({ kind }: { kind: string }) {
|
function AbortedBadge({ kind }: { kind: string }) {
|
||||||
|
const { t } = useI18n();
|
||||||
return (
|
return (
|
||||||
<div data-testid="job-aborted-badge" className="flex items-center gap-2 text-xs text-muted-foreground">
|
<div data-testid="job-aborted-badge" className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
<CircleX className="size-3.5" />
|
<CircleX className="size-3.5" />
|
||||||
<span className="font-medium uppercase tracking-wide">{kind}</span>
|
<span className="font-medium uppercase tracking-wide">{kind}</span>
|
||||||
<span>aborted by the learner</span>
|
<span>{t("aborted by the learner")}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -80,6 +83,7 @@ function AbortedBadge({ kind }: { kind: string }) {
|
|||||||
* the badge marks delivery, the collapsed details carries the full text
|
* the badge marks delivery, the collapsed details carries the full text
|
||||||
* + consulted pages. */
|
* + consulted pages. */
|
||||||
function PdfCompleteBadge({ result }: { result: PdfResult }) {
|
function PdfCompleteBadge({ result }: { result: PdfResult }) {
|
||||||
|
const { t } = useI18n();
|
||||||
// The honest count is READ pages (extract/render — sources v1 split);
|
// The honest count is READ pages (extract/render — sources v1 split);
|
||||||
// the ?? is runtime tolerance for pre-split persisted steers.
|
// the ?? is runtime tolerance for pre-split persisted steers.
|
||||||
const readPages = result.pagesRead ?? result.pagesConsulted;
|
const readPages = result.pagesRead ?? result.pagesConsulted;
|
||||||
@@ -88,14 +92,14 @@ function PdfCompleteBadge({ result }: { result: PdfResult }) {
|
|||||||
<summary className="flex cursor-pointer select-none list-none items-center gap-2 [&::-webkit-details-marker]:hidden">
|
<summary className="flex cursor-pointer select-none list-none items-center gap-2 [&::-webkit-details-marker]:hidden">
|
||||||
<CheckIcon className="size-3.5" />
|
<CheckIcon className="size-3.5" />
|
||||||
<span className="font-medium uppercase tracking-wide">PDF</span>
|
<span className="font-medium uppercase tracking-wide">PDF</span>
|
||||||
<span>reading complete</span>
|
<span>{t("reading complete")}</span>
|
||||||
{readPages.length > 0 && (
|
{readPages.length > 0 && (
|
||||||
<span>
|
<span>
|
||||||
· read {readPages.length} page{readPages.length === 1 ? "" : "s"}
|
{t("· read {count} pages", { count: readPages.length })}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="ml-1 text-xs text-muted-foreground/70 group-open:hidden">view brief</span>
|
<span className="ml-1 text-xs text-muted-foreground/70 group-open:hidden">{t("view brief")}</span>
|
||||||
<span className="ml-1 hidden text-xs text-muted-foreground/70 group-open:inline">hide brief</span>
|
<span className="ml-1 hidden text-xs text-muted-foreground/70 group-open:inline">{t("hide brief")}</span>
|
||||||
</summary>
|
</summary>
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<PdfBriefCard output={result} />
|
<PdfBriefCard output={result} />
|
||||||
@@ -110,15 +114,16 @@ function PdfCompleteBadge({ result }: { result: PdfResult }) {
|
|||||||
* affordance): full markdown + sources, one click away, no extra fetch —
|
* affordance): full markdown + sources, one click away, no extra fetch —
|
||||||
* the artifact part in the record is the single source of truth. */
|
* the artifact part in the record is the single source of truth. */
|
||||||
function ResearchCompleteBadge({ result }: { result: ResearchResult }) {
|
function ResearchCompleteBadge({ result }: { result: ResearchResult }) {
|
||||||
|
const { t } = useI18n();
|
||||||
return (
|
return (
|
||||||
<details data-testid="research-complete-badge" className="group w-full text-xs text-muted-foreground">
|
<details data-testid="research-complete-badge" className="group w-full text-xs text-muted-foreground">
|
||||||
<summary className="flex cursor-pointer select-none list-none items-center gap-2 [&::-webkit-details-marker]:hidden">
|
<summary className="flex cursor-pointer select-none list-none items-center gap-2 [&::-webkit-details-marker]:hidden">
|
||||||
<CheckIcon className="size-3.5" />
|
<CheckIcon className="size-3.5" />
|
||||||
<span className="font-medium uppercase tracking-wide">Research</span>
|
<span className="font-medium uppercase tracking-wide">{t("Research")}</span>
|
||||||
<span>complete</span>
|
<span>{t("complete")}</span>
|
||||||
{result.sources.length > 0 && <span>· {result.sources.length} sources</span>}
|
{result.sources.length > 0 && <span>· {t("{count} sources", { count: result.sources.length })}</span>}
|
||||||
<span className="ml-1 text-xs text-muted-foreground/70 group-open:hidden">view brief</span>
|
<span className="ml-1 text-xs text-muted-foreground/70 group-open:hidden">{t("view brief")}</span>
|
||||||
<span className="ml-1 hidden text-xs text-muted-foreground/70 group-open:inline">hide brief</span>
|
<span className="ml-1 hidden text-xs text-muted-foreground/70 group-open:inline">{t("hide brief")}</span>
|
||||||
</summary>
|
</summary>
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<ResearchBriefCard output={result} />
|
<ResearchBriefCard output={result} />
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { useI18n } from "@/components/i18n";
|
||||||
|
|
||||||
|
|
||||||
import { ChevronRight } from "lucide-react";
|
import { ChevronRight } from "lucide-react";
|
||||||
import type { ArtifactPartProps } from "@/src/core";
|
import type { ArtifactPartProps } from "@/src/core";
|
||||||
@@ -62,6 +64,7 @@ function outputText(output: unknown): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ToolCallCard({ part }: ArtifactPartProps) {
|
export function ToolCallCard({ part }: ArtifactPartProps) {
|
||||||
|
const { t } = useI18n();
|
||||||
const p = part as ToolCallPart;
|
const p = part as ToolCallPart;
|
||||||
const tool = p.type.replace(/^tool-/, "");
|
const tool = p.type.replace(/^tool-/, "");
|
||||||
const arg = headlineArg(tool, p.input);
|
const arg = headlineArg(tool, p.input);
|
||||||
@@ -84,8 +87,7 @@ export function ToolCallCard({ part }: ArtifactPartProps) {
|
|||||||
{p.input != null && (
|
{p.input != null && (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-1 font-medium uppercase tracking-wide text-muted-foreground">
|
<div className="mb-1 font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
Input
|
{t("Input")}</div>
|
||||||
</div>
|
|
||||||
<pre className="max-h-32 overflow-auto whitespace-pre-wrap break-all font-mono text-[11px] leading-relaxed">
|
<pre className="max-h-32 overflow-auto whitespace-pre-wrap break-all font-mono text-[11px] leading-relaxed">
|
||||||
{JSON.stringify(p.input, null, 2)}
|
{JSON.stringify(p.input, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
@@ -94,24 +96,22 @@ export function ToolCallCard({ part }: ArtifactPartProps) {
|
|||||||
{typeof p.errorText === "string" ? (
|
{typeof p.errorText === "string" ? (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-1 font-medium uppercase tracking-wide text-muted-foreground">
|
<div className="mb-1 font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
Error
|
{t("Error")}</div>
|
||||||
</div>
|
|
||||||
<pre className="whitespace-pre-wrap break-all font-mono text-[11px] text-red-500">
|
<pre className="whitespace-pre-wrap break-all font-mono text-[11px] text-red-500">
|
||||||
{p.errorText}
|
{t(p.errorText)}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
) : out != null ? (
|
) : out != null ? (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-1 font-medium uppercase tracking-wide text-muted-foreground">
|
<div className="mb-1 font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
Output
|
{t("Output")}</div>
|
||||||
</div>
|
|
||||||
<pre className="max-h-64 overflow-auto whitespace-pre-wrap break-all font-mono text-[11px] leading-relaxed">
|
<pre className="max-h-64 overflow-auto whitespace-pre-wrap break-all font-mono text-[11px] leading-relaxed">
|
||||||
{out}
|
{out}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
p.state !== "output-available" && (
|
p.state !== "output-available" && (
|
||||||
<div className="text-muted-foreground italic">running…</div>
|
<div className="text-muted-foreground italic">{t("running…")}</div>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { useI18n } from "@/components/i18n";
|
||||||
|
|
||||||
|
|
||||||
import { useContext, useState } from "react";
|
import { useContext, useState } from "react";
|
||||||
import { ChatActionsContext } from "@/components/chat";
|
import { ChatActionsContext } from "@/components/chat";
|
||||||
@@ -61,6 +63,7 @@ export function VisualOutputCard({
|
|||||||
messageId?: string;
|
messageId?: string;
|
||||||
initialInverted?: boolean;
|
initialInverted?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
const result = output as VisualResult | undefined;
|
const result = output as VisualResult | undefined;
|
||||||
const overlay = useSessionMermaid();
|
const overlay = useSessionMermaid();
|
||||||
const actions = useContext(ChatActionsContext);
|
const actions = useContext(ChatActionsContext);
|
||||||
@@ -83,8 +86,7 @@ export function VisualOutputCard({
|
|||||||
if (!result || typeof result.svg !== "string") {
|
if (!result || typeof result.svg !== "string") {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border bg-card p-4 text-sm text-muted-foreground">
|
<div className="rounded-xl border bg-card p-4 text-sm text-muted-foreground">
|
||||||
Figure completed, but the result could not be displayed.
|
{t("Figure completed, but the result could not be displayed.")}</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const failed = result.svg.length === 0;
|
const failed = result.svg.length === 0;
|
||||||
@@ -92,10 +94,9 @@ export function VisualOutputCard({
|
|||||||
return (
|
return (
|
||||||
<div className="rounded-xl border bg-card p-4 text-card-foreground" data-testid="visual-card-failed">
|
<div className="rounded-xl border bg-card p-4 text-card-foreground" data-testid="visual-card-failed">
|
||||||
<div className="flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
<div className="flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
Figure
|
{t("Figure")}<span className="rounded bg-red-500/15 px-1.5 py-0.5 text-red-500">{t("failed")}</span>
|
||||||
<span className="rounded bg-red-500/15 px-1.5 py-0.5 text-red-500">failed</span>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-sm text-muted-foreground">{result.notes ?? "The figure could not be drawn."}</p>
|
<p className="mt-2 text-sm text-muted-foreground">{result.notes ?? t("The figure could not be drawn.")}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -130,21 +131,21 @@ export function VisualOutputCard({
|
|||||||
data-testid="visual-svg"
|
data-testid="visual-svg"
|
||||||
/>
|
/>
|
||||||
<div className="mt-2 flex items-center justify-between gap-2">
|
<div className="mt-2 flex items-center justify-between gap-2">
|
||||||
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Figure</div>
|
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">{t("Figure")}</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{/* The invert affordance (owner round, 2026-10-07): the disc IS
|
{/* The invert affordance (owner round, 2026-10-07): the disc IS
|
||||||
the half-filled contrast circle; the tooltip names it. */}
|
the half-filled contrast circle; the tooltip names it. */}
|
||||||
<Toggle
|
<Toggle
|
||||||
checked={inverted}
|
checked={inverted}
|
||||||
onChange={toggleInverted}
|
onChange={toggleInverted}
|
||||||
label="Invert figure brightness"
|
label={t("Invert figure brightness")}
|
||||||
title="Invert colors"
|
title={t("Invert colors")}
|
||||||
discIcon={<InvertDiscIcon className="size-3" />}
|
discIcon={<InvertDiscIcon className="size-3" />}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Download figure as SVG"
|
aria-label={t("Download figure as SVG")}
|
||||||
title="Download as SVG"
|
title={t("Download as SVG")}
|
||||||
onClick={() => downloadFile("figure.svg", result.svg, "image/svg+xml")}
|
onClick={() => downloadFile("figure.svg", result.svg, "image/svg+xml")}
|
||||||
data-testid="visual-download"
|
data-testid="visual-download"
|
||||||
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||||
@@ -154,8 +155,8 @@ export function VisualOutputCard({
|
|||||||
{overlay && (
|
{overlay && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Open figure fullscreen"
|
aria-label={t("Open figure fullscreen")}
|
||||||
title="Open fullscreen"
|
title={t("Open fullscreen")}
|
||||||
onClick={() => overlay.openFigure(result.svg, inverted)}
|
onClick={() => overlay.openFigure(result.svg, inverted)}
|
||||||
data-testid="visual-open"
|
data-testid="visual-open"
|
||||||
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useI18n } from "@/components/i18n";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -43,12 +46,13 @@ export function BrailleSpinner({
|
|||||||
/** The accessible name of the state this spinner announces. */
|
/** The accessible name of the state this spinner announces. */
|
||||||
label?: string;
|
label?: string;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
viewBox="0 0 4 8"
|
viewBox="0 0 4 8"
|
||||||
data-testid="braille-spinner"
|
data-testid="braille-spinner"
|
||||||
role="status"
|
role="status"
|
||||||
aria-label={label}
|
aria-label={t(label)}
|
||||||
className={cn("h-3 w-auto shrink-0 text-foreground", className)}
|
className={cn("h-3 w-auto shrink-0 text-foreground", className)}
|
||||||
>
|
>
|
||||||
{FRAMES.map((frame, k) => (
|
{FRAMES.map((frame, k) => (
|
||||||
|
|||||||
+26
-26
@@ -1,4 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { countLabel } from "@/src/core";
|
||||||
|
import { useI18n } from "@/components/i18n";
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* THE CHAIN TABLE (chains overlay; owner refinements round 2, 2026-09-29):
|
* THE CHAIN TABLE (chains overlay; owner refinements round 2, 2026-09-29):
|
||||||
@@ -143,6 +146,7 @@ export function ChainTableBody({
|
|||||||
selectMode?: boolean;
|
selectMode?: boolean;
|
||||||
onSelect?: () => void;
|
onSelect?: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t, language } = useI18n();
|
||||||
const byId = useMemo(() => new Map(nodes.map((n) => [n.id, n])), [nodes]);
|
const byId = useMemo(() => new Map(nodes.map((n) => [n.id, n])), [nodes]);
|
||||||
const chains = useMemo(() => chainsForSelection(nodes, storedChains), [nodes, storedChains]);
|
const chains = useMemo(() => chainsForSelection(nodes, storedChains), [nodes, storedChains]);
|
||||||
const searching = query.trim() !== "";
|
const searching = query.trim() !== "";
|
||||||
@@ -246,7 +250,7 @@ export function ChainTableBody({
|
|||||||
law: later in the DOM). */}
|
law: later in the DOM). */}
|
||||||
<TopBar>
|
<TopBar>
|
||||||
<span className="min-w-0 flex-1 truncate text-xs text-muted-foreground">
|
<span className="min-w-0 flex-1 truncate text-xs text-muted-foreground">
|
||||||
{chains.length} {chains.length === 1 ? "chain" : "chains"} · {nodes.length} {nodes.length === 1 ? "node" : "nodes"}
|
{countLabel(language, chains.length, "chain", "chains")} · {countLabel(language, nodes.length, "node", "nodes")}
|
||||||
</span>
|
</span>
|
||||||
<div className="relative shrink-0">
|
<div className="relative shrink-0">
|
||||||
<SearchIcon className="absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
<SearchIcon className="absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||||
@@ -254,8 +258,8 @@ export function ChainTableBody({
|
|||||||
value={query}
|
value={query}
|
||||||
data-testid="chain-table-search"
|
data-testid="chain-table-search"
|
||||||
onChange={(e) => onQuery(e.target.value)}
|
onChange={(e) => onQuery(e.target.value)}
|
||||||
placeholder="Search nodes…"
|
placeholder={t("Search nodes…")}
|
||||||
aria-label="Search nodes"
|
aria-label={t("Search nodes")}
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
className="lh-no-drag h-8 w-56 rounded-lg border bg-background pl-8 pr-3 text-xs outline-none placeholder:text-muted-foreground focus:border-ring"
|
className="lh-no-drag h-8 w-56 rounded-lg border bg-background pl-8 pr-3 text-xs outline-none placeholder:text-muted-foreground focus:border-ring"
|
||||||
/>
|
/>
|
||||||
@@ -264,7 +268,7 @@ export function ChainTableBody({
|
|||||||
type="button"
|
type="button"
|
||||||
data-testid="chain-table-close"
|
data-testid="chain-table-close"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
aria-label="Close chains"
|
aria-label={t("Close chains")}
|
||||||
className="lh-no-drag flex size-8 shrink-0 items-center justify-center rounded-lg border border-border/70 bg-card/80 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
className="lh-no-drag flex size-8 shrink-0 items-center justify-center rounded-lg border border-border/70 bg-card/80 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||||
>
|
>
|
||||||
<XIcon className="size-4" />
|
<XIcon className="size-4" />
|
||||||
@@ -288,7 +292,7 @@ export function ChainTableBody({
|
|||||||
aria-sort={sort.col === "name" ? (sort.dir === 1 ? "ascending" : "descending") : "none"}
|
aria-sort={sort.col === "name" ? (sort.dir === 1 ? "ascending" : "descending") : "none"}
|
||||||
className="flex flex-1 items-center gap-1 text-left transition-colors hover:text-foreground"
|
className="flex flex-1 items-center gap-1 text-left transition-colors hover:text-foreground"
|
||||||
>
|
>
|
||||||
Name {dirIcon("name")}
|
{t("Name")}{" "}{dirIcon("name")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -297,7 +301,7 @@ export function ChainTableBody({
|
|||||||
aria-sort={sort.col === "props" ? (sort.dir === 1 ? "ascending" : "descending") : "none"}
|
aria-sort={sort.col === "props" ? (sort.dir === 1 ? "ascending" : "descending") : "none"}
|
||||||
className="flex w-14 shrink-0 items-center justify-end gap-1 text-right transition-colors hover:text-foreground"
|
className="flex w-14 shrink-0 items-center justify-end gap-1 text-right transition-colors hover:text-foreground"
|
||||||
>
|
>
|
||||||
Props {dirIcon("props")}
|
{t("Props")}{" "}{dirIcon("props")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -306,14 +310,13 @@ export function ChainTableBody({
|
|||||||
aria-sort={sort.col === "created" ? (sort.dir === 1 ? "ascending" : "descending") : "none"}
|
aria-sort={sort.col === "created" ? (sort.dir === 1 ? "ascending" : "descending") : "none"}
|
||||||
className="flex w-20 shrink-0 items-center justify-end gap-1 text-right transition-colors hover:text-foreground"
|
className="flex w-20 shrink-0 items-center justify-end gap-1 text-right transition-colors hover:text-foreground"
|
||||||
>
|
>
|
||||||
Created {dirIcon("created")}
|
{t("Created")}{" "}{dirIcon("created")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{groups.length === 0 && (
|
{groups.length === 0 && (
|
||||||
<div data-testid="chain-table-nomatch" className="px-4 pt-3 text-xs text-muted-foreground">
|
<div data-testid="chain-table-nomatch" className="px-4 pt-3 text-xs text-muted-foreground">
|
||||||
No matching chains.
|
{t("No matching chains.")}</div>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{groups.map((group) => {
|
{groups.map((group) => {
|
||||||
@@ -348,15 +351,15 @@ export function ChainTableBody({
|
|||||||
indeterminate={selectedCount > 0 && selectedCount < group.nodeIds.length}
|
indeterminate={selectedCount > 0 && selectedCount < group.nodeIds.length}
|
||||||
onToggle={(next) => onToggleNodes(group.nodeIds, next)}
|
onToggle={(next) => onToggleNodes(group.nodeIds, next)}
|
||||||
testid={`cluster-checkbox-${group.key}`}
|
testid={`cluster-checkbox-${group.key}`}
|
||||||
label={`Select everything born in ${group.label ?? "unlabeled"}`}
|
label={t("Select everything born in {v0}", { v0: group.label ?? "unlabeled" })}
|
||||||
/>
|
/>
|
||||||
<Expander open={open} onClick={() => onToggleCluster(group.key)} testid={`cluster-toggle-${group.key}`} label={`Expand cluster ${group.label ?? "unlabeled"}`} />
|
<Expander open={open} onClick={() => onToggleCluster(group.key)} testid={`cluster-toggle-${group.key}`} label={t("Expand cluster {v0}", { v0: group.label ?? "unlabeled" })} />
|
||||||
<span className="flex shrink-0 items-center justify-center text-muted-foreground">
|
<span className="flex shrink-0 items-center justify-center text-muted-foreground">
|
||||||
{open ? <FolderOpenIcon className="size-3.5" /> : <FolderIcon className="size-3.5" />}
|
{open ? <FolderOpenIcon className="size-3.5" /> : <FolderIcon className="size-3.5" />}
|
||||||
</span>
|
</span>
|
||||||
<span className="min-w-0 flex-1 truncate text-sm font-medium">
|
<span className="min-w-0 flex-1 truncate text-sm font-medium">
|
||||||
{group.label ?? <span className="text-muted-foreground/60">unlabeled</span>}
|
{group.label ?? <span className="text-muted-foreground/60">{t("unlabeled")}</span>}
|
||||||
{group.kind === "notes" && <span className="ml-2 text-[11px] uppercase tracking-widest text-muted-foreground/60">notes</span>}
|
{group.kind === "notes" && <span className="ml-2 text-[11px] uppercase tracking-widest text-muted-foreground/60">{t("notes")}</span>}
|
||||||
</span>
|
</span>
|
||||||
<span className="w-14 shrink-0 text-right text-xs tabular-nums text-muted-foreground">
|
<span className="w-14 shrink-0 text-right text-xs tabular-nums text-muted-foreground">
|
||||||
{group.nodeIds.length}
|
{group.nodeIds.length}
|
||||||
@@ -397,9 +400,9 @@ export function ChainTableBody({
|
|||||||
indeterminate={selectedInChain > 0 && selectedInChain < memberIds.length}
|
indeterminate={selectedInChain > 0 && selectedInChain < memberIds.length}
|
||||||
onToggle={(next) => onToggleNodes(memberIds, next)}
|
onToggle={(next) => onToggleNodes(memberIds, next)}
|
||||||
testid={`chain-checkbox-${chain.id}`}
|
testid={`chain-checkbox-${chain.id}`}
|
||||||
label={`Select chain ${nameOf(chain.conclusion)}`}
|
label={t("Select chain {v0}", { v0: nameOf(chain.conclusion) })}
|
||||||
/>
|
/>
|
||||||
<Expander open={chainOpen} onClick={() => onToggleChain(chain.id)} testid={`chain-toggle-${chain.id}`} label={`Expand chain ${nameOf(chain.conclusion)}`} />
|
<Expander open={chainOpen} onClick={() => onToggleChain(chain.id)} testid={`chain-toggle-${chain.id}`} label={t("Expand chain {v0}", { v0: nameOf(chain.conclusion) })} />
|
||||||
{/* The chain line: proposition names as faint-outlined
|
{/* The chain line: proposition names as faint-outlined
|
||||||
chips. THE LOGIC IS THE PAYLOAD (owner rulings,
|
chips. THE LOGIC IS THE PAYLOAD (owner rulings,
|
||||||
2026-09-30): premises join with the LaTeX LAND (∧),
|
2026-09-30): premises join with the LaTeX LAND (∧),
|
||||||
@@ -465,7 +468,7 @@ export function ChainTableBody({
|
|||||||
checked={selection.has(id)}
|
checked={selection.has(id)}
|
||||||
onToggle={(next) => onToggleNodes([id], next)}
|
onToggle={(next) => onToggleNodes([id], next)}
|
||||||
testid={`prop-checkbox-${id}`}
|
testid={`prop-checkbox-${id}`}
|
||||||
label={`Select node ${node.name}`}
|
label={t("Select node {v0}", { v0: node.name })}
|
||||||
/>
|
/>
|
||||||
<span className="min-w-0 flex-1 text-xs leading-relaxed text-muted-foreground">
|
<span className="min-w-0 flex-1 text-xs leading-relaxed text-muted-foreground">
|
||||||
{node.statement}
|
{node.statement}
|
||||||
@@ -493,8 +496,8 @@ export function ChainTableBody({
|
|||||||
className="pointer-events-auto flex items-center gap-3 rounded-full border bg-card px-4 py-2 shadow-lg"
|
className="pointer-events-auto flex items-center gap-3 rounded-full border bg-card px-4 py-2 shadow-lg"
|
||||||
>
|
>
|
||||||
<span data-testid="chain-selected-summary" className="text-xs font-medium">
|
<span data-testid="chain-selected-summary" className="text-xs font-medium">
|
||||||
{selection.size} {selection.size === 1 ? "node" : "nodes"} →{" "}
|
{countLabel(language, selection.size, "node", "nodes")} →{" "}
|
||||||
{derived.length} {derived.length === 1 ? "chain" : "chains"}
|
{countLabel(language, derived.length, "chain", "chains")}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -502,19 +505,17 @@ export function ChainTableBody({
|
|||||||
onClick={onClear}
|
onClick={onClear}
|
||||||
className="rounded-full px-2.5 py-1 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
className="rounded-full px-2.5 py-1 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||||
>
|
>
|
||||||
Clear
|
{t("Clear")}</button>
|
||||||
</button>
|
|
||||||
{selectMode ? (
|
{selectMode ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="chain-practice-done"
|
data-testid="chain-practice-done"
|
||||||
onClick={onSelect}
|
onClick={onSelect}
|
||||||
aria-label={`Done picking — ${selection.size} ${selection.size === 1 ? "proposition" : "propositions"} selected`}
|
aria-label={t("Done picking — {v0} {v1} selected", { v0: selection.size, v1: selection.size === 1 ? "proposition" : "propositions" })}
|
||||||
className="flex items-center gap-1.5 rounded-full bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/80"
|
className="flex items-center gap-1.5 rounded-full bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/80"
|
||||||
>
|
>
|
||||||
<DumbbellIcon aria-hidden="true" className="size-3.5" />
|
<DumbbellIcon aria-hidden="true" className="size-3.5" />
|
||||||
Done
|
{t("Done")}</button>
|
||||||
</button>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* The danger fence (owner ruling): a divider quarantines
|
{/* The danger fence (owner ruling): a divider quarantines
|
||||||
@@ -526,12 +527,11 @@ export function ChainTableBody({
|
|||||||
type="button"
|
type="button"
|
||||||
data-testid="chain-delete"
|
data-testid="chain-delete"
|
||||||
onClick={onDelete}
|
onClick={onDelete}
|
||||||
aria-label={`Delete ${selection.size} selected ${selection.size === 1 ? "node" : "nodes"}`}
|
aria-label={t("Delete {v0} selected {v1}", { v0: selection.size, v1: selection.size === 1 ? "node" : "nodes" })}
|
||||||
className="flex items-center gap-1.5 rounded-full border border-destructive/50 bg-destructive/10 px-3 py-1.5 text-xs font-medium text-destructive transition-colors hover:bg-destructive/20"
|
className="flex items-center gap-1.5 rounded-full border border-destructive/50 bg-destructive/10 px-3 py-1.5 text-xs font-medium text-destructive transition-colors hover:bg-destructive/20"
|
||||||
>
|
>
|
||||||
<Trash2Icon aria-hidden="true" className="size-3.5" />
|
<Trash2Icon aria-hidden="true" className="size-3.5" />
|
||||||
Delete
|
{t("Delete")}</button>
|
||||||
</button>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+40
-31
@@ -1,4 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { useI18n } from "@/components/i18n";
|
||||||
|
|
||||||
|
|
||||||
import { createContext, Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
import { createContext, Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useChatSession } from "@/components/session";
|
import { useChatSession } from "@/components/session";
|
||||||
@@ -57,7 +59,7 @@ import { PracticeDock } from "@/components/practice-dock";
|
|||||||
import { ThinkingCard, type PracticeThinkingData } from "@/components/practice-cards";
|
import { ThinkingCard, type PracticeThinkingData } from "@/components/practice-cards";
|
||||||
import { GraphContextCard, type GraphContextData } from "@/components/graph-context-card";
|
import { GraphContextCard, type GraphContextData } from "@/components/graph-context-card";
|
||||||
import { TopBar } from "@/components/top-bar";
|
import { TopBar } from "@/components/top-bar";
|
||||||
import type { ArtifactPartProps, JobRecord, Phase3, QuestionAnswerData, TurnUsage } from "@/src/core";
|
import type { ArtifactPartProps, JobRecord, Phase3, QuestionAnswerData, TurnUsage, SessionPricing } from "@/src/core";
|
||||||
import type { LhViewMessage } from "@/src/core";
|
import type { LhViewMessage } from "@/src/core";
|
||||||
import { PhaseChip, PhaseDivider } from "@/components/phase-divider";
|
import { PhaseChip, PhaseDivider } from "@/components/phase-divider";
|
||||||
import {
|
import {
|
||||||
@@ -225,6 +227,7 @@ export function Chat({
|
|||||||
* only decides whether the pill rides the composer. */
|
* only decides whether the pill rides the composer. */
|
||||||
initialConfigured?: boolean;
|
initialConfigured?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
// Reattach plumbing (background-turn runner wave): the hook holds the
|
// Reattach plumbing (background-turn runner wave): the hook holds the
|
||||||
// settled terminal's callback — a stable ref assigned below, once
|
// settled terminal's callback — a stable ref assigned below, once
|
||||||
// syncFromServer exists (refs, not closures: no stale capture).
|
// syncFromServer exists (refs, not closures: no stale capture).
|
||||||
@@ -236,7 +239,7 @@ export function Chat({
|
|||||||
// (core/view.ts projectView); sends are ONE explicit input event each
|
// (core/view.ts projectView); sends are ONE explicit input event each
|
||||||
// ({chatId, input} — the stash indirection died with the SDK), and the
|
// ({chatId, input} — the stash indirection died with the SDK), and the
|
||||||
// turn stream is the VERBATIM AgentEvent passthrough the server emits.
|
// turn stream is the VERBATIM AgentEvent passthrough the server emits.
|
||||||
const { messages, nativeMessages, send, steer, steerQueue, submitAnswer, dismissQuestion, dismissProblem, setMessages, adoptRecord, status, error, stop, clearError, resumeStream, setReattachSettled } =
|
const { messages, nativeMessages, contextTokens, send, steer, steerQueue, submitAnswer, dismissQuestion, dismissProblem, setMessages, adoptRecord, status, error, stop, clearError, resumeStream, setReattachSettled } =
|
||||||
useChatSession({ chatId, initialMessages, initiallyInFlight });
|
useChatSession({ chatId, initialMessages, initiallyInFlight });
|
||||||
|
|
||||||
const sessionFindScopeRef = useRef<HTMLDivElement | null>(null);
|
const sessionFindScopeRef = useRef<HTMLDivElement | null>(null);
|
||||||
@@ -717,8 +720,9 @@ export function Chat({
|
|||||||
const syncFromServer = useCallback(async () => {
|
const syncFromServer = useCallback(async () => {
|
||||||
const res = await fetch(`/api/chats/${chatId}`);
|
const res = await fetch(`/api/chats/${chatId}`);
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const record = (await res.json()) as { messages?: LhMessage[]; turns?: TurnUsage[] };
|
const record = (await res.json()) as { messages?: LhMessage[]; turns?: TurnUsage[]; pricing?: SessionPricing };
|
||||||
if (Array.isArray(record.turns)) setTurns(record.turns);
|
if (Array.isArray(record.turns)) setTurns(record.turns);
|
||||||
|
setPricing(record.pricing);
|
||||||
if (!Array.isArray(record.messages)) return;
|
if (!Array.isArray(record.messages)) return;
|
||||||
setMessages(record.messages);
|
setMessages(record.messages);
|
||||||
// The continuation seam (contracts.ts protocol §4): a job result
|
// The continuation seam (contracts.ts protocol §4): a job result
|
||||||
@@ -845,12 +849,13 @@ export function Chat({
|
|||||||
const planTree = useMemo(() => latestTree(messages), [messages]);
|
const planTree = useMemo(() => latestTree(messages), [messages]);
|
||||||
|
|
||||||
// ── Session usage (wave-4 stats bar) ──
|
// ── Session usage (wave-4 stats bar) ──
|
||||||
// The money channel: the record's turns log, folded client-side by
|
// The money channel: the record's turns log and read-time server Pi
|
||||||
// core/prices.ts. Seeded once on mount (the page doesn't carry turns),
|
// pricing projection. Seeded once on mount (the page doesn't carry turns),
|
||||||
// refreshed when the local stream settles (each turn appends to the log
|
// refreshed when the local stream settles (each turn appends to the log
|
||||||
// server-side), and kept fresh by syncFromServer whenever it already
|
// server-side), and kept fresh by syncFromServer whenever it already
|
||||||
// fetches the record. No turns yet → the bar renders "—" everywhere.
|
// fetches the record. No turns yet → the bar renders "—" everywhere.
|
||||||
const [turns, setTurns] = useState<TurnUsage[]>([]);
|
const [turns, setTurns] = useState<TurnUsage[]>([]);
|
||||||
|
const [pricing, setPricing] = useState<SessionPricing | undefined>();
|
||||||
// The per-chat model control state (model-picker writes via PUT; the
|
// The per-chat model control state (model-picker writes via PUT; the
|
||||||
// record is the source of truth, lifted here so the stats bar re-renders).
|
// record is the source of truth, lifted here so the stats bar re-renders).
|
||||||
// The dock's reserved tail height (DockOverlay reports it; the spacer at
|
// The dock's reserved tail height (DockOverlay reports it; the spacer at
|
||||||
@@ -864,8 +869,9 @@ export function Chat({
|
|||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/chats/${chatId}`);
|
const res = await fetch(`/api/chats/${chatId}`);
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const record = (await res.json()) as { turns?: TurnUsage[] };
|
const record = (await res.json()) as { turns?: TurnUsage[]; pricing?: SessionPricing };
|
||||||
if (Array.isArray(record.turns)) setTurns(record.turns);
|
if (Array.isArray(record.turns)) setTurns(record.turns);
|
||||||
|
setPricing(record.pricing);
|
||||||
} catch {
|
} catch {
|
||||||
// transient blip: stats stay on the last known figures
|
// transient blip: stats stay on the last known figures
|
||||||
}
|
}
|
||||||
@@ -1233,8 +1239,8 @@ export function Chat({
|
|||||||
}}
|
}}
|
||||||
onBlur={() => void commitRename(titleDraft)}
|
onBlur={() => void commitRename(titleDraft)}
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
aria-label="Session title"
|
aria-label={t("Session title")}
|
||||||
title="Rename this session"
|
title={t("Rename this session")}
|
||||||
className="lh-no-drag min-w-[8ch] max-w-[70%] shrink-0 bg-transparent font-title text-3xl tracking-tight caret-current outline-none [field-sizing:content]"
|
className="lh-no-drag min-w-[8ch] max-w-[70%] shrink-0 bg-transparent font-title text-3xl tracking-tight caret-current outline-none [field-sizing:content]"
|
||||||
/>
|
/>
|
||||||
{/* flex-1 spacer during rename too, so the chips stay at
|
{/* flex-1 spacer during rename too, so the chips stay at
|
||||||
@@ -1246,7 +1252,7 @@ export function Chat({
|
|||||||
<>
|
<>
|
||||||
<Hint
|
<Hint
|
||||||
// F2 renames (registry) — surfaced on the hover.
|
// F2 renames (registry) — surfaced on the hover.
|
||||||
label="Rename session"
|
label={t("Rename session")}
|
||||||
action="rename-session"
|
action="rename-session"
|
||||||
side="bottom"
|
side="bottom"
|
||||||
>
|
>
|
||||||
@@ -1270,7 +1276,7 @@ export function Chat({
|
|||||||
structure beside the phase chip — access to the full-DAG popup
|
structure beside the phase chip — access to the full-DAG popup
|
||||||
at every width; the marginalia is the wide-screen glance view. */}
|
at every width; the marginalia is the wide-screen glance view. */}
|
||||||
{planTree && (
|
{planTree && (
|
||||||
<Hint label="Open the full plan" side="bottom">
|
<Hint label={t("Open the full plan")} side="bottom">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="plan-chip"
|
data-testid="plan-chip"
|
||||||
@@ -1281,8 +1287,7 @@ export function Chat({
|
|||||||
className="lh-no-drag flex shrink-0 cursor-pointer items-center gap-1.5 rounded-full border border-border/80 px-2 py-0.5 text-xs font-medium uppercase tracking-wide text-muted-foreground transition-colors hover:border-border hover:text-foreground"
|
className="lh-no-drag flex shrink-0 cursor-pointer items-center gap-1.5 rounded-full border border-border/80 px-2 py-0.5 text-xs font-medium uppercase tracking-wide text-muted-foreground transition-colors hover:border-border hover:text-foreground"
|
||||||
>
|
>
|
||||||
<WaypointsIcon className="size-3.5 text-primary" aria-hidden />
|
<WaypointsIcon className="size-3.5 text-primary" aria-hidden />
|
||||||
Plan
|
{t("Plan")}</button>
|
||||||
</button>
|
|
||||||
</Hint>
|
</Hint>
|
||||||
)}
|
)}
|
||||||
{/* The extraction ring (owner-locked redesign, 2026-09-24): fills
|
{/* The extraction ring (owner-locked redesign, 2026-09-24): fills
|
||||||
@@ -1303,8 +1308,7 @@ export function Chat({
|
|||||||
className="pointer-events-none flex shrink-0 select-none items-center gap-1 text-xs font-medium uppercase tracking-wide text-muted-foreground"
|
className="pointer-events-none flex shrink-0 select-none items-center gap-1 text-xs font-medium uppercase tracking-wide text-muted-foreground"
|
||||||
>
|
>
|
||||||
<DumbbellIcon className="size-3" />
|
<DumbbellIcon className="size-3" />
|
||||||
Practice
|
{t("Practice")}</span>
|
||||||
</span>
|
|
||||||
) : (
|
) : (
|
||||||
<PhaseChip phase={phases.at(-1) ?? "probe"} />
|
<PhaseChip phase={phases.at(-1) ?? "probe"} />
|
||||||
)}
|
)}
|
||||||
@@ -1318,8 +1322,8 @@ export function Chat({
|
|||||||
<div data-session-find-root="" className={`${COLUMN} flex flex-1 flex-col gap-4 pt-4`}>
|
<div data-session-find-root="" className={`${COLUMN} flex flex-1 flex-col gap-4 pt-4`}>
|
||||||
{messages.length === 0 && !busy && (
|
{messages.length === 0 && !busy && (
|
||||||
<ConversationEmptyState
|
<ConversationEmptyState
|
||||||
title="New session"
|
title={t("New session")}
|
||||||
description="The teacher will open by probing what you already know."
|
description={t("The teacher will open by probing what you already know.")}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1370,8 +1374,7 @@ export function Chat({
|
|||||||
className="my-4 flex items-center justify-center gap-1.5 font-ui text-xs font-medium uppercase tracking-wide text-muted-foreground"
|
className="my-4 flex items-center justify-center gap-1.5 font-ui text-xs font-medium uppercase tracking-wide text-muted-foreground"
|
||||||
>
|
>
|
||||||
<ArrowRightIcon className="size-3" />
|
<ArrowRightIcon className="size-3" />
|
||||||
Next problem
|
{t("Next problem")}</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
@@ -1429,7 +1432,7 @@ export function Chat({
|
|||||||
{/* The rail GROUPS (owner round 4, 2026-10-05): the YOU heading
|
{/* The rail GROUPS (owner round 4, 2026-10-05): the YOU heading
|
||||||
sits INSIDE the rail, same mono-xs register and same 1ch
|
sits INSIDE the rail, same mono-xs register and same 1ch
|
||||||
rail as the thinking rows — one quote geometry. */}
|
rail as the thinking rows — one quote geometry. */}
|
||||||
<div className="font-medium uppercase tracking-wide text-muted-foreground">You</div>
|
<div className="font-medium uppercase tracking-wide text-muted-foreground">{t("You")}</div>
|
||||||
<div className="lh-prose whitespace-pre-wrap text-foreground">
|
<div className="lh-prose whitespace-pre-wrap text-foreground">
|
||||||
{m.parts.filter((p) => p.type === "text").map((p) => p.text).join("\n")}
|
{m.parts.filter((p) => p.type === "text").map((p) => p.text).join("\n")}
|
||||||
</div>
|
</div>
|
||||||
@@ -1462,8 +1465,7 @@ export function Chat({
|
|||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
<SystemLine key={`retry-${pi}`} testId="tool-retry">
|
<SystemLine key={`retry-${pi}`} testId="tool-retry">
|
||||||
Retrying…
|
{t("Retrying…")}</SystemLine>
|
||||||
</SystemLine>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
switch (part.type) {
|
switch (part.type) {
|
||||||
@@ -1559,21 +1561,20 @@ export function Chat({
|
|||||||
BEFORE beginTurn; the settle kick runs after endTurn). */}
|
BEFORE beginTurn; the settle kick runs after endTurn). */}
|
||||||
{compacting && (
|
{compacting && (
|
||||||
<span className="flex items-center gap-2 font-ui text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
<span className="flex items-center gap-2 font-ui text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
<BrailleSpinner label="Compacting context" />
|
<BrailleSpinner label={t("Compacting context")} />
|
||||||
<Shimmer duration={1}>COMPACTING CONTEXT…</Shimmer>
|
<Shimmer duration={1}>{t("COMPACTING CONTEXT…")}</Shimmer>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{(status === "submitted" || turnInFlight) && (
|
{(status === "submitted" || turnInFlight) && (
|
||||||
<span className="flex items-center gap-2 font-ui text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
<span className="flex items-center gap-2 font-ui text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
<BrailleSpinner label="Thinking" />
|
<BrailleSpinner label={t("Thinking")} />
|
||||||
<Shimmer duration={1}>
|
<Shimmer duration={1}>
|
||||||
THINKING…
|
{t("THINKING…")}</Shimmer>
|
||||||
</Shimmer>
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm">
|
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm">
|
||||||
Something went wrong: {error.message}
|
{t("Something went wrong:")}{" "}{t(error.message)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1604,6 +1605,8 @@ export function Chat({
|
|||||||
onReadingThinking={applyReadingThinking}
|
onReadingThinking={applyReadingThinking}
|
||||||
phase={phases.at(-1) ?? "probe"}
|
phase={phases.at(-1) ?? "probe"}
|
||||||
turns={turns}
|
turns={turns}
|
||||||
|
contextTokens={contextTokens}
|
||||||
|
pricing={pricing}
|
||||||
onReserve={setDockReserve}
|
onReserve={setDockReserve}
|
||||||
dictationSlot={<DictationBar dictation={dictation} />}
|
dictationSlot={<DictationBar dictation={dictation} />}
|
||||||
dictationCancel={dictation.cancel}
|
dictationCancel={dictation.cancel}
|
||||||
@@ -1620,8 +1623,7 @@ export function Chat({
|
|||||||
onClick={() => setSetupOpen(true)}
|
onClick={() => setSetupOpen(true)}
|
||||||
className="rounded-sm border border-border bg-card px-3 py-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50 hover:bg-accent cursor-pointer"
|
className="rounded-sm border border-border bg-card px-3 py-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50 hover:bg-accent cursor-pointer"
|
||||||
>
|
>
|
||||||
Set up your models
|
{t("Set up your models")}</button>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -1645,7 +1647,7 @@ export function Chat({
|
|||||||
// applies.
|
// applies.
|
||||||
nextProblem={nextReady}
|
nextProblem={nextReady}
|
||||||
onNext={sendNext}
|
onNext={sendNext}
|
||||||
placeholder={nextReady ? "Ask anything, or press Enter for the next one." : undefined}
|
placeholder={nextReady ? t("Ask anything, or press Enter for the next one.") : undefined}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
onSubmitAnswer={(output) => {
|
onSubmitAnswer={(output) => {
|
||||||
@@ -1824,6 +1826,8 @@ function DockOverlay({
|
|||||||
onReadingThinking,
|
onReadingThinking,
|
||||||
phase,
|
phase,
|
||||||
turns,
|
turns,
|
||||||
|
contextTokens,
|
||||||
|
pricing,
|
||||||
composer,
|
composer,
|
||||||
onSubmitAnswer,
|
onSubmitAnswer,
|
||||||
chatId,
|
chatId,
|
||||||
@@ -1855,6 +1859,8 @@ function DockOverlay({
|
|||||||
phase: Phase3;
|
phase: Phase3;
|
||||||
/** The session's usage log — the stats bar beneath the composer folds it. */
|
/** The session's usage log — the stats bar beneath the composer folds it. */
|
||||||
turns: TurnUsage[];
|
turns: TurnUsage[];
|
||||||
|
contextTokens: number | null;
|
||||||
|
pricing?: SessionPricing;
|
||||||
composer: React.ReactNode;
|
composer: React.ReactNode;
|
||||||
onSubmitAnswer: (output: QuestionAnswerData) => void;
|
onSubmitAnswer: (output: QuestionAnswerData) => void;
|
||||||
/** The model control's pass-through (the stats bar hosts it). */
|
/** The model control's pass-through (the stats bar hosts it). */
|
||||||
@@ -1880,6 +1886,7 @@ function DockOverlay({
|
|||||||
* home and /chat/new carry, above the composer while slots are unset. */
|
* home and /chat/new carry, above the composer while slots are unset. */
|
||||||
setupCta?: React.ReactNode;
|
setupCta?: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
const { isAtBottom, scrollToBottom, scrollRef } = useStickToBottomContext();
|
const { isAtBottom, scrollToBottom, scrollRef } = useStickToBottomContext();
|
||||||
// Render-fresh bottom state (flicker round): the arrival block below reads
|
// Render-fresh bottom state (flicker round): the arrival block below reads
|
||||||
// it DURING RENDER — the same value the after-paint effect used to read a
|
// it DURING RENDER — the same value the after-paint effect used to read a
|
||||||
@@ -2327,6 +2334,8 @@ function DockOverlay({
|
|||||||
morphing shell so it never joins a height animation. */}
|
morphing shell so it never joins a height animation. */}
|
||||||
<StatsBar
|
<StatsBar
|
||||||
turns={turns}
|
turns={turns}
|
||||||
|
contextTokens={contextTokens}
|
||||||
|
pricing={pricing}
|
||||||
dictationSlot={dictationSlot}
|
dictationSlot={dictationSlot}
|
||||||
chatId={chatId}
|
chatId={chatId}
|
||||||
/>
|
/>
|
||||||
@@ -2354,7 +2363,7 @@ function DockOverlay({
|
|||||||
mid-question unmount), and widening past the floor fades it
|
mid-question unmount), and widening past the floor fades it
|
||||||
back in. */}
|
back in. */}
|
||||||
{pending && plan && (
|
{pending && plan && (
|
||||||
<Hint label="Open the full plan" side="bottom">
|
<Hint label={t("Open the full plan")} side="bottom">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="plan-marginalia"
|
data-testid="plan-marginalia"
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { useI18n } from "@/components/i18n";
|
||||||
|
|
||||||
|
|
||||||
import type { CompactionDetails } from "@/src/core";
|
import type { CompactionDetails } from "@/src/core";
|
||||||
|
|
||||||
@@ -19,11 +21,11 @@ export interface CompactionDividerData {
|
|||||||
* history was just summarized; the cut divider below carries the seam.
|
* history was just summarized; the cut divider below carries the seam.
|
||||||
* Inert since release polish 2026-10-06 — plain text, no affordance. */
|
* Inert since release polish 2026-10-06 — plain text, no affordance. */
|
||||||
export function CompactionMomentDivider({ data: _data }: { data: CompactionDividerData }) {
|
export function CompactionMomentDivider({ data: _data }: { data: CompactionDividerData }) {
|
||||||
|
const { t } = useI18n();
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full items-center justify-center py-1">
|
<div className="flex w-full items-center justify-center py-1">
|
||||||
<span data-testid="compaction-divider" className="px-1.5 text-xs text-muted-foreground/70">
|
<span data-testid="compaction-divider" className="px-1.5 text-xs text-muted-foreground/70">
|
||||||
Context compacted
|
{t("Context compacted")}</span>
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -35,12 +37,13 @@ export function CompactionMomentDivider({ data: _data }: { data: CompactionDivid
|
|||||||
* since release polish 2026-10-06 — the token count stays, the summary
|
* since release polish 2026-10-06 — the token count stays, the summary
|
||||||
* is inspectable only in provider logs. */
|
* is inspectable only in provider logs. */
|
||||||
export function CompactionCutDivider({ data }: { data: CompactionDividerData }) {
|
export function CompactionCutDivider({ data }: { data: CompactionDividerData }) {
|
||||||
const kTok = data.details.tokensBefore > 0 ? ` · ${Math.round(data.details.tokensBefore / 1000)}k tok` : "";
|
const { t } = useI18n();
|
||||||
|
const kTok = data.details.tokensBefore > 0 ? t(" · {v0}k tok", { v0: Math.round(data.details.tokensBefore / 1000) }) : "";
|
||||||
return (
|
return (
|
||||||
<div data-testid="compaction-cut-divider" className="my-4 flex w-full items-center gap-3">
|
<div data-testid="compaction-cut-divider" className="my-4 flex w-full items-center gap-3">
|
||||||
<span className="h-0 flex-1 border-t-2 border-dotted border-border/50" aria-hidden />
|
<span className="h-0 flex-1 border-t-2 border-dotted border-border/50" aria-hidden />
|
||||||
<span className="rounded-full border border-border/80 px-2.5 py-0.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
<span className="rounded-full border border-border/80 px-2.5 py-0.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
History compacted{kTok}
|
{t("History compacted")}{kTok}
|
||||||
</span>
|
</span>
|
||||||
<span className="h-0 flex-1 border-t-2 border-dotted border-border/50" aria-hidden />
|
<span className="h-0 flex-1 border-t-2 border-dotted border-border/50" aria-hidden />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { useI18n } from "@/components/i18n";
|
||||||
|
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { DumbbellIcon } from "lucide-react";
|
import { DumbbellIcon } from "lucide-react";
|
||||||
@@ -25,6 +27,7 @@ export function NodePickerControl({ count, onOpen, onClear }: {
|
|||||||
onOpen?: () => void;
|
onOpen?: () => void;
|
||||||
onClear?: () => void;
|
onClear?: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<button
|
<button
|
||||||
@@ -39,7 +42,7 @@ export function NodePickerControl({ count, onOpen, onClear }: {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<DumbbellIcon aria-hidden="true" className="size-3.5 text-muted-foreground" />
|
<DumbbellIcon aria-hidden="true" className="size-3.5 text-muted-foreground" />
|
||||||
{count > 0 ? `${count} ${count === 1 ? "node" : "nodes"} selected` : "Pick nodes"}
|
{count > 0 ? t("{v0} {v1} selected", { v0: count, v1: count === 1 ? "node" : "nodes" }) : t("Pick nodes")}
|
||||||
</button>
|
</button>
|
||||||
{count > 0 && (
|
{count > 0 && (
|
||||||
<button
|
<button
|
||||||
@@ -48,8 +51,7 @@ export function NodePickerControl({ count, onOpen, onClear }: {
|
|||||||
onClick={onClear}
|
onClick={onClear}
|
||||||
className="cursor-pointer rounded-full px-2 py-1 font-ui text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
className="cursor-pointer rounded-full px-2 py-1 font-ui text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||||
>
|
>
|
||||||
Clear
|
{t("Clear")}</button>
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { useI18n } from "@/components/i18n";
|
||||||
|
|
||||||
|
|
||||||
import { ArrowRightIcon, CornerDownLeftIcon, SquareIcon } from "lucide-react";
|
import { ArrowRightIcon, CornerDownLeftIcon, SquareIcon } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -75,6 +77,7 @@ export function Composer({
|
|||||||
* empty-Enter share it. */
|
* empty-Enter share it. */
|
||||||
onNext?: () => void;
|
onNext?: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
const act = () => {
|
const act = () => {
|
||||||
const action = enterAction({ busy, canSend, nextProblem });
|
const action = enterAction({ busy, canSend, nextProblem });
|
||||||
if (action === "stop") onStop();
|
if (action === "stop") onStop();
|
||||||
@@ -113,7 +116,7 @@ export function Composer({
|
|||||||
act();
|
act();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder={placeholder}
|
placeholder={t(placeholder)}
|
||||||
autoFocus={autoFocus}
|
autoFocus={autoFocus}
|
||||||
rows={1}
|
rows={1}
|
||||||
className="field-sizing-content lh-input max-h-48 min-h-10 flex-1 resize-none bg-transparent px-2.5 py-2 outline-none placeholder:text-muted-foreground"
|
className="field-sizing-content lh-input max-h-48 min-h-10 flex-1 resize-none bg-transparent px-2.5 py-2 outline-none placeholder:text-muted-foreground"
|
||||||
@@ -136,16 +139,15 @@ export function Composer({
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
data-testid="next-problem-button"
|
data-testid="next-problem-button"
|
||||||
aria-label="Next problem"
|
aria-label={t("Next problem")}
|
||||||
className="flex h-10 shrink-0 items-center justify-center gap-1.5 rounded-[10px] bg-green-600 px-4 font-ui text-xs font-medium uppercase tracking-wide text-white transition-colors hover:bg-green-500"
|
className="flex h-10 shrink-0 items-center justify-center gap-1.5 rounded-[10px] bg-green-600 px-4 font-ui text-xs font-medium uppercase tracking-wide text-white transition-colors hover:bg-green-500"
|
||||||
>
|
>
|
||||||
Next problem
|
{t("Next problem")}<ArrowRightIcon className="size-4" />
|
||||||
<ArrowRightIcon className="size-4" />
|
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
aria-label={busy && !canSend ? "Stop" : "Send"}
|
aria-label={busy && !canSend ? t("Stop") : t("Send")}
|
||||||
disabled={!busy && !canSend}
|
disabled={!busy && !canSend}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex size-10 shrink-0 items-center justify-center rounded-[10px] transition-colors",
|
"flex size-10 shrink-0 items-center justify-center rounded-[10px] transition-colors",
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { useI18n } from "@/components/i18n";
|
||||||
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
@@ -45,17 +47,18 @@ export function DeleteNodeBody({
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onRun: () => void;
|
onRun: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label="Delete node"
|
aria-label={t("Delete node")}
|
||||||
data-testid="delete-node-dialog"
|
data-testid="delete-node-dialog"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="w-[32rem] max-w-[calc(100vw-2rem)] rounded-xl border bg-card p-5 shadow-xl"
|
className="w-[32rem] max-w-[calc(100vw-2rem)] rounded-xl border bg-card p-5 shadow-xl"
|
||||||
>
|
>
|
||||||
<h2 className="text-sm font-semibold text-destructive">
|
<h2 className="text-sm font-semibold text-destructive">
|
||||||
{nodes.length === 1 ? "Delete node?" : `Delete ${nodes.length} nodes?`}
|
{nodes.length === 1 ? t("Delete node?") : t("Delete {v0} nodes?", { v0: nodes.length })}
|
||||||
</h2>
|
</h2>
|
||||||
{/* The node name(s) below the title in JetBrains Mono (owner,
|
{/* The node name(s) below the title in JetBrains Mono (owner,
|
||||||
2026-09-24): the name is the payload — mono suits it, and
|
2026-09-24): the name is the payload — mono suits it, and
|
||||||
@@ -74,14 +77,14 @@ export function DeleteNodeBody({
|
|||||||
) : (
|
) : (
|
||||||
<code data-testid="node-names-code" className="font-mono text-sm text-foreground">
|
<code data-testid="node-names-code" className="font-mono text-sm text-foreground">
|
||||||
{nodes.slice(0, 8).map((n) => n.name).join(", ")}
|
{nodes.slice(0, 8).map((n) => n.name).join(", ")}
|
||||||
{nodes.length > 8 ? `, and ${nodes.length - 8} more` : ""}
|
{nodes.length > 8 ? t(", and {v0} more", { v0: nodes.length - 8 }) : ""}
|
||||||
</code>
|
</code>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-sm text-muted-foreground" data-testid="node-permanence-line">
|
<p className="mt-2 text-sm text-muted-foreground" data-testid="node-permanence-line">
|
||||||
{nodes.length === 1
|
{nodes.length === 1
|
||||||
? "This permanently deletes the node — this cannot be undone."
|
? t("This permanently deletes the node — this cannot be undone.")
|
||||||
: `This permanently deletes all ${nodes.length} nodes — this cannot be undone.`}
|
: t("This permanently deletes all {v0} nodes — this cannot be undone.", { v0: nodes.length })}
|
||||||
</p>
|
</p>
|
||||||
{failure && (
|
{failure && (
|
||||||
<div
|
<div
|
||||||
@@ -90,7 +93,7 @@ export function DeleteNodeBody({
|
|||||||
className="mt-3 rounded-sm border border-border/60 px-3 py-2 text-xs"
|
className="mt-3 rounded-sm border border-border/60 px-3 py-2 text-xs"
|
||||||
>
|
>
|
||||||
<span className="text-foreground">
|
<span className="text-foreground">
|
||||||
{failure.code === "extraction_running" ? EXTRACTION_RETRY_LINE : failure.error}
|
{t(failure.code === "extraction_running" ? EXTRACTION_RETRY_LINE : failure.error)}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -99,8 +102,7 @@ export function DeleteNodeBody({
|
|||||||
disabled={busy}
|
disabled={busy}
|
||||||
className="ml-3 text-xs text-foreground underline-offset-2 hover:underline disabled:opacity-40"
|
className="ml-3 text-xs text-foreground underline-offset-2 hover:underline disabled:opacity-40"
|
||||||
>
|
>
|
||||||
Try again
|
{t("Try again")}</button>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="mt-4 flex justify-end gap-2">
|
<div className="mt-4 flex justify-end gap-2">
|
||||||
@@ -111,8 +113,7 @@ export function DeleteNodeBody({
|
|||||||
disabled={busy}
|
disabled={busy}
|
||||||
className="rounded-sm px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground disabled:opacity-40"
|
className="rounded-sm px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground disabled:opacity-40"
|
||||||
>
|
>
|
||||||
Cancel
|
{t("Cancel")}</button>
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="node-delete-confirm"
|
data-testid="node-delete-confirm"
|
||||||
@@ -123,7 +124,7 @@ export function DeleteNodeBody({
|
|||||||
// refusal banner): border + text, fill on hover.
|
// refusal banner): border + text, fill on hover.
|
||||||
className="rounded-sm border border-destructive/50 bg-destructive/10 px-3 py-1.5 text-xs text-destructive transition-colors hover:bg-destructive/20 disabled:opacity-40"
|
className="rounded-sm border border-destructive/50 bg-destructive/10 px-3 py-1.5 text-xs text-destructive transition-colors hover:bg-destructive/20 disabled:opacity-40"
|
||||||
>
|
>
|
||||||
{busy ? "Deleting…" : "Delete"}
|
{busy ? t("Deleting…") : t("Delete")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -146,6 +147,7 @@ export function DeleteNodeDialog({
|
|||||||
* selection-side callers can prune. */
|
* selection-side callers can prune. */
|
||||||
onDeleted: (nodeIds: string[], graphRevision: number) => void;
|
onDeleted: (nodeIds: string[], graphRevision: number) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const backdropDismiss = useBackdropDismiss(() => {
|
const backdropDismiss = useBackdropDismiss(() => {
|
||||||
if (!busy) onClose();
|
if (!busy) onClose();
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { useI18n } from "@/components/i18n";
|
||||||
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
@@ -50,6 +52,7 @@ export function DeleteSessionDialog({
|
|||||||
/** Success path — the sidebar owns navigation + list patching. */
|
/** Success path — the sidebar owns navigation + list patching. */
|
||||||
onDeleted: (id: string, mode: ChatDeleteMode, graphRevision: number) => void;
|
onDeleted: (id: string, mode: ChatDeleteMode, graphRevision: number) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
const [traces, setTraces] = useState(false);
|
const [traces, setTraces] = useState(false);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const backdropDismiss = useBackdropDismiss(() => {
|
const backdropDismiss = useBackdropDismiss(() => {
|
||||||
@@ -155,16 +158,17 @@ export function DeleteSessionBody({
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onRun: () => void;
|
onRun: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label="Delete session"
|
aria-label={t("Delete session")}
|
||||||
data-testid="delete-session-dialog"
|
data-testid="delete-session-dialog"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="w-[32rem] max-w-[calc(100vw-2rem)] rounded-xl border bg-card p-5 shadow-xl"
|
className="w-[32rem] max-w-[calc(100vw-2rem)] rounded-xl border bg-card p-5 shadow-xl"
|
||||||
>
|
>
|
||||||
<h2 className="text-sm font-semibold text-destructive">Delete session?</h2>
|
<h2 className="text-sm font-semibold text-destructive">{t("Delete session?")}</h2>
|
||||||
{/* The session name below the title in JetBrains Mono (owner,
|
{/* The session name below the title in JetBrains Mono (owner,
|
||||||
2026-09-24): no code-block chrome — the font already says
|
2026-09-24): no code-block chrome — the font already says
|
||||||
code. Owner, 2026-10-13 polish: normal text-sm size (was
|
code. Owner, 2026-10-13 polish: normal text-sm size (was
|
||||||
@@ -176,9 +180,7 @@ export function DeleteSessionBody({
|
|||||||
</code>
|
</code>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
This permanently removes the session — its messages, background jobs, and extraction records. Everything
|
{t("This permanently removes the session — its messages, background jobs, and extraction records. Everything it added to the graph stays unless you check below. This cannot be undone.")}</p>
|
||||||
it added to the graph stays unless you check below. This cannot be undone.
|
|
||||||
</p>
|
|
||||||
<label
|
<label
|
||||||
data-testid={TRACES_COPY.testid}
|
data-testid={TRACES_COPY.testid}
|
||||||
className="mt-4 flex cursor-pointer items-start gap-2.5 rounded-lg border border-border/60 px-4 py-3"
|
className="mt-4 flex cursor-pointer items-start gap-2.5 rounded-lg border border-border/60 px-4 py-3"
|
||||||
@@ -192,8 +194,8 @@ export function DeleteSessionBody({
|
|||||||
className="mt-0.5 size-4 shrink-0 accent-primary"
|
className="mt-0.5 size-4 shrink-0 accent-primary"
|
||||||
/>
|
/>
|
||||||
<span className="min-w-0">
|
<span className="min-w-0">
|
||||||
<span className="block text-sm text-foreground">{TRACES_COPY.title}</span>
|
<span className="block text-sm text-foreground">{t(TRACES_COPY.title)}</span>
|
||||||
<span className="mt-1 block text-xs text-muted-foreground">{TRACES_COPY.body}</span>
|
<span className="mt-1 block text-xs text-muted-foreground">{t(TRACES_COPY.body)}</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
{failure && (
|
{failure && (
|
||||||
@@ -202,7 +204,7 @@ export function DeleteSessionBody({
|
|||||||
data-testid="delete-failure"
|
data-testid="delete-failure"
|
||||||
className="mt-3 rounded-sm border border-border/60 px-3 py-2 text-xs"
|
className="mt-3 rounded-sm border border-border/60 px-3 py-2 text-xs"
|
||||||
>
|
>
|
||||||
<span className="text-foreground">{failure.error}</span>
|
<span className="text-foreground">{t(failure.error)}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="delete-retry"
|
data-testid="delete-retry"
|
||||||
@@ -210,8 +212,7 @@ export function DeleteSessionBody({
|
|||||||
disabled={busy}
|
disabled={busy}
|
||||||
className="ml-3 text-xs text-foreground underline-offset-2 hover:underline disabled:opacity-40"
|
className="ml-3 text-xs text-foreground underline-offset-2 hover:underline disabled:opacity-40"
|
||||||
>
|
>
|
||||||
Try again
|
{t("Try again")}</button>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* The node dialog's exact bottom row: quiet Cancel, destructive
|
{/* The node dialog's exact bottom row: quiet Cancel, destructive
|
||||||
@@ -225,8 +226,7 @@ export function DeleteSessionBody({
|
|||||||
disabled={busy}
|
disabled={busy}
|
||||||
className="rounded-sm px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground disabled:opacity-40"
|
className="rounded-sm px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground disabled:opacity-40"
|
||||||
>
|
>
|
||||||
Cancel
|
{t("Cancel")}</button>
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="delete-confirm"
|
data-testid="delete-confirm"
|
||||||
@@ -234,7 +234,7 @@ export function DeleteSessionBody({
|
|||||||
disabled={busy}
|
disabled={busy}
|
||||||
className="rounded-sm border border-destructive/50 bg-destructive/10 px-3 py-1.5 text-xs text-destructive transition-colors hover:bg-destructive/20 disabled:opacity-40"
|
className="rounded-sm border border-destructive/50 bg-destructive/10 px-3 py-1.5 text-xs text-destructive transition-colors hover:bg-destructive/20 disabled:opacity-40"
|
||||||
>
|
>
|
||||||
{busy ? "Deleting…" : "Delete"}
|
{busy ? t("Deleting…") : t("Delete")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+23
-17
@@ -3,6 +3,8 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { Loader2Icon, MicIcon } from "lucide-react";
|
import { Loader2Icon, MicIcon } from "lucide-react";
|
||||||
import { useSettingsDialog } from "@/components/settings/settings-dialog";
|
import { useSettingsDialog } from "@/components/settings/settings-dialog";
|
||||||
|
import { dictationUrl } from "@/src/core";
|
||||||
|
import { LanguageSelect, useI18n } from "@/components/i18n";
|
||||||
import { Hint } from "@/components/ui/hint";
|
import { Hint } from "@/components/ui/hint";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -86,15 +88,7 @@ import { Hint } from "@/components/ui/hint";
|
|||||||
* endpointing=300, no interims. Silence streams too (the worklet never
|
* endpointing=300, no interims. Silence streams too (the worklet never
|
||||||
* pauses), so the socket never idles — no KeepAlive needed.
|
* pauses), so the socket never idles — no KeepAlive needed.
|
||||||
*/
|
*/
|
||||||
const DG_URL =
|
|
||||||
"wss://api.deepgram.com/v1/listen" +
|
|
||||||
"?model=nova-3" +
|
|
||||||
"&encoding=linear16" +
|
|
||||||
"&sample_rate=16000" +
|
|
||||||
"&channels=1" +
|
|
||||||
"&smart_format=true" +
|
|
||||||
"&punctuate=true" +
|
|
||||||
"&endpointing=300";
|
|
||||||
|
|
||||||
/** Meter tuning — the owner's tested values, see the file header. */
|
/** Meter tuning — the owner's tested values, see the file header. */
|
||||||
const METER_FLOOR_DB = -50;
|
const METER_FLOOR_DB = -50;
|
||||||
@@ -285,6 +279,10 @@ export type Dictation = {
|
|||||||
* question waits.
|
* question waits.
|
||||||
*/
|
*/
|
||||||
export function useDictation({ append }: { append: (text: string) => void }): Dictation {
|
export function useDictation({ append }: { append: (text: string) => void }): Dictation {
|
||||||
|
const { sttLanguage, general, setDictating } = useI18n();
|
||||||
|
const selectedUrl = useRef("");
|
||||||
|
selectedUrl.current = dictationUrl(sttLanguage, general.sttPreferredLanguages);
|
||||||
|
const recordingUrl = useRef(selectedUrl.current);
|
||||||
const [phase, setPhase] = useState<DictationPhase>("idle");
|
const [phase, setPhase] = useState<DictationPhase>("idle");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
/** Latest RMS from the worklet — the meter samples this (dictate pattern). */
|
/** Latest RMS from the worklet — the meter samples this (dictate pattern). */
|
||||||
@@ -330,6 +328,7 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
|
|||||||
const cleanup = useCallback(() => {
|
const cleanup = useCallback(() => {
|
||||||
genRef.current++;
|
genRef.current++;
|
||||||
dictationActiveFlag = false;
|
dictationActiveFlag = false;
|
||||||
|
setDictating(false);
|
||||||
flush(); // the deferred transcript appears when the run ends (owner round 2)
|
flush(); // the deferred transcript appears when the run ends (owner round 2)
|
||||||
if (safetyRef.current) {
|
if (safetyRef.current) {
|
||||||
clearTimeout(safetyRef.current);
|
clearTimeout(safetyRef.current);
|
||||||
@@ -366,7 +365,7 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
|
|||||||
// Minted JWTs authenticate via the "bearer" subprotocol — ["token",
|
// Minted JWTs authenticate via the "bearer" subprotocol — ["token",
|
||||||
// jwt] is rejected at the handshake (verified live; "token" is for
|
// jwt] is rejected at the handshake (verified live; "token" is for
|
||||||
// raw API keys only, as in the owner's pi dictate extension).
|
// raw API keys only, as in the owner's pi dictate extension).
|
||||||
const ws = new WebSocket(DG_URL, ["bearer", token]);
|
const ws = new WebSocket(recordingUrl.current, ["bearer", token]);
|
||||||
wsRef.current = ws;
|
wsRef.current = ws;
|
||||||
|
|
||||||
ws.addEventListener("open", () => {
|
ws.addEventListener("open", () => {
|
||||||
@@ -426,6 +425,9 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
|
|||||||
);
|
);
|
||||||
|
|
||||||
const start = useCallback(async () => {
|
const start = useCallback(async () => {
|
||||||
|
// Pin the complete configuration once; reconnects must retain both language and hints.
|
||||||
|
recordingUrl.current = selectedUrl.current;
|
||||||
|
setDictating(true);
|
||||||
dictationActiveFlag = true;
|
dictationActiveFlag = true;
|
||||||
setError(null);
|
setError(null);
|
||||||
setPhase("connecting");
|
setPhase("connecting");
|
||||||
@@ -622,6 +624,7 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
|
|||||||
* (full text in the tooltip), cleared on the next click.
|
* (full text in the tooltip), cleared on the next click.
|
||||||
*/
|
*/
|
||||||
export function DictationBar({ dictation }: { dictation: Dictation }) {
|
export function DictationBar({ dictation }: { dictation: Dictation }) {
|
||||||
|
const { t } = useI18n();
|
||||||
const { phase, error, levelRef, toggle, active, clearError } = dictation;
|
const { phase, error, levelRef, toggle, active, clearError } = dictation;
|
||||||
const { open: openSettings } = useSettingsDialog();
|
const { open: openSettings } = useSettingsDialog();
|
||||||
/** The keyless click (zero-keys wave, owner dictation trio): the errored
|
/** The keyless click (zero-keys wave, owner dictation trio): the errored
|
||||||
@@ -673,12 +676,13 @@ export function DictationBar({ dictation }: { dictation: Dictation }) {
|
|||||||
|
|
||||||
if (!active) {
|
if (!active) {
|
||||||
return (
|
return (
|
||||||
|
<div className="flex w-full min-w-0 items-center gap-1.5">
|
||||||
<Hint
|
<Hint
|
||||||
// Errored: the button span truncates the line — the tooltip is its
|
// Errored: the button span truncates the line — the tooltip is its
|
||||||
// full reveal (the keyless line KEEPS "Deepgram" — owner overrule:
|
// full reveal (the keyless line KEEPS "Deepgram" — owner overrule:
|
||||||
// it's the Keys row label, the truth the user needs). Otherwise the
|
// it's the Keys row label, the truth the user needs). Otherwise the
|
||||||
// quiet label + the live ⌘D binding.
|
// quiet label + the live ⌘D binding.
|
||||||
label={error ?? "Dictate"}
|
label={error ? t(error) : t("Dictate")}
|
||||||
action={error ? undefined : "toggle-dictation"}
|
action={error ? undefined : "toggle-dictation"}
|
||||||
side="top"
|
side="top"
|
||||||
>
|
>
|
||||||
@@ -692,16 +696,18 @@ export function DictationBar({ dictation }: { dictation: Dictation }) {
|
|||||||
toggle();
|
toggle();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
aria-label={error ? `Dictation failed: ${error}` : "Start dictation"}
|
aria-label={error ? t("Dictation failed: {v0}", { v0: error }) : t("Start dictation")}
|
||||||
data-testid="dictation-toggle"
|
data-testid="dictation-toggle"
|
||||||
// Same blurring as the floating chrome (owner, fix wave round 3),
|
// Same blurring as the floating chrome (owner, fix wave round 3),
|
||||||
// but lower opacity — the mic stays quieter than the model pills.
|
// but lower opacity — the mic stays quieter than the model pills.
|
||||||
className="flex h-7 w-full items-center justify-center gap-1.5 rounded-md border border-border/50 bg-card/50 text-xs text-muted-foreground/70 shadow-lg backdrop-blur transition-colors hover:border-border hover:text-muted-foreground"
|
className="flex h-7 w-full items-center justify-center gap-1.5 rounded-md border border-border/50 bg-card/50 text-xs text-muted-foreground/70 shadow-lg backdrop-blur transition-colors hover:border-border hover:text-muted-foreground"
|
||||||
>
|
>
|
||||||
<MicIcon className="size-3.5" />
|
<MicIcon className="size-3.5" />
|
||||||
<span className="max-w-full truncate">{error ?? "Dictate"}</span>
|
<span className="max-w-full truncate">{error ? t(error) : t("Dictate")}</span>
|
||||||
</button>
|
</button>
|
||||||
</Hint>
|
</Hint>
|
||||||
|
<LanguageSelect field="sttLanguage" compact />
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -710,14 +716,14 @@ export function DictationBar({ dictation }: { dictation: Dictation }) {
|
|||||||
<Hint
|
<Hint
|
||||||
// The owner flagged this copy good ("Stop dictation (Esc)") —
|
// The owner flagged this copy good ("Stop dictation (Esc)") —
|
||||||
// kept verbatim; only the machinery moved to the styled tooltip.
|
// kept verbatim; only the machinery moved to the styled tooltip.
|
||||||
label="Stop dictation (Esc)"
|
label={t("Stop dictation (Esc)")}
|
||||||
side="top"
|
side="top"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
disabled={phase === "stopping"}
|
disabled={phase === "stopping"}
|
||||||
aria-label="Stop dictation"
|
aria-label={t("Stop dictation")}
|
||||||
data-testid="dictation-toggle"
|
data-testid="dictation-toggle"
|
||||||
className="flex h-7 shrink-0 items-center gap-1.5 rounded-md bg-destructive/10 px-2 text-xs font-medium text-destructive transition-colors hover:bg-destructive/20"
|
className="flex h-7 shrink-0 items-center gap-1.5 rounded-md bg-destructive/10 px-2 text-xs font-medium text-destructive transition-colors hover:bg-destructive/20"
|
||||||
>
|
>
|
||||||
@@ -726,13 +732,13 @@ export function DictationBar({ dictation }: { dictation: Dictation }) {
|
|||||||
) : (
|
) : (
|
||||||
<MicIcon className="size-3.5" />
|
<MicIcon className="size-3.5" />
|
||||||
)}
|
)}
|
||||||
Stop
|
{t("Stop")}</button>
|
||||||
</button>
|
|
||||||
</Hint>
|
</Hint>
|
||||||
<span className="size-1.5 shrink-0 animate-pulse rounded-full bg-destructive" aria-hidden />
|
<span className="size-1.5 shrink-0 animate-pulse rounded-full bg-destructive" aria-hidden />
|
||||||
{/* Fixed width while recording — nothing ever appends text beside it
|
{/* Fixed width while recording — nothing ever appends text beside it
|
||||||
(no interims, owner round 1). Cells carry no minimum width; the
|
(no interims, owner round 1). Cells carry no minimum width; the
|
||||||
COUNT adapts to the measured row space (owner round 2). */}
|
COUNT adapts to the measured row space (owner round 2). */}
|
||||||
|
<LanguageSelect field="sttLanguage" compact disabled />
|
||||||
<div ref={meterRef} className="flex h-4 min-w-0 flex-1 items-center gap-[2px]" data-testid="dictation-meter">
|
<div ref={meterRef} className="flex h-4 min-w-0 flex-1 items-center gap-[2px]" data-testid="dictation-meter">
|
||||||
{levels.slice(-cellCount).map((v, i) => (
|
{levels.slice(-cellCount).map((v, i) => (
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { useI18n } from "@/components/i18n";
|
||||||
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useActivity } from "@/components/activity-dock";
|
import { useActivity } from "@/components/activity-dock";
|
||||||
@@ -183,6 +185,7 @@ export function CoverageMenuBody({
|
|||||||
extracting: boolean;
|
extracting: boolean;
|
||||||
onExtract: () => void;
|
onExtract: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t, language } = useI18n();
|
||||||
const busy = coverage.running || extracting;
|
const busy = coverage.running || extracting;
|
||||||
const complete = coverage.uncoveredTokens === 0 && coverage.uncoveredCount === 0;
|
const complete = coverage.uncoveredTokens === 0 && coverage.uncoveredCount === 0;
|
||||||
return (
|
return (
|
||||||
@@ -190,17 +193,15 @@ export function CoverageMenuBody({
|
|||||||
{busy ? (
|
{busy ? (
|
||||||
<span className="flex items-center gap-1.5 text-muted-foreground" data-testid="ctx-coverage-running">
|
<span className="flex items-center gap-1.5 text-muted-foreground" data-testid="ctx-coverage-running">
|
||||||
<span className="size-1.5 animate-pulse rounded-full bg-foreground/70" aria-hidden />
|
<span className="size-1.5 animate-pulse rounded-full bg-foreground/70" aria-hidden />
|
||||||
extracting…
|
{t("extracting…")}</span>
|
||||||
</span>
|
|
||||||
) : complete ? (
|
) : complete ? (
|
||||||
<span className="text-muted-foreground">fully extracted</span>
|
<span className="text-muted-foreground">{t("fully extracted")}</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="flex flex-col">
|
<span className="flex flex-col">
|
||||||
<span className="tabular-nums text-foreground">
|
<span className="tabular-nums text-foreground">
|
||||||
{formatTokens(coverage.uncoveredTokens)} tok uncovered
|
{formatTokens(coverage.uncoveredTokens, language)}{" "}{t("tok uncovered")}</span>
|
||||||
</span>
|
|
||||||
<span className="tabular-nums text-muted-foreground">
|
<span className="tabular-nums text-muted-foreground">
|
||||||
{coverage.uncoveredCount} message{coverage.uncoveredCount === 1 ? "" : "s"} unextracted
|
{t("{count} messages unextracted", { count: coverage.uncoveredCount })}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -211,7 +212,7 @@ export function CoverageMenuBody({
|
|||||||
disabled={busy || complete}
|
disabled={busy || complete}
|
||||||
className="flex items-center justify-center rounded-lg border px-2 py-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40 disabled:hover:bg-transparent"
|
className="flex items-center justify-center rounded-lg border px-2 py-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40 disabled:hover:bg-transparent"
|
||||||
>
|
>
|
||||||
{busy ? "Running" : "Extract"}
|
{busy ? t("Running") : t("Extract")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -241,6 +242,7 @@ export function ringFill(uncoveredTokens: number, chunkTokens: number): number {
|
|||||||
* threshold/kill-switch re-read live).
|
* threshold/kill-switch re-read live).
|
||||||
*/
|
*/
|
||||||
export function CoverageRing({ chatId, refreshKey = 0 }: { chatId: string; refreshKey?: number }) {
|
export function CoverageRing({ chatId, refreshKey = 0 }: { chatId: string; refreshKey?: number }) {
|
||||||
|
const { t } = useI18n();
|
||||||
const [graph, setGraph] = useState<GraphRead | null>(null);
|
const [graph, setGraph] = useState<GraphRead | null>(null);
|
||||||
const [coverage, setCoverage] = useState<SessionCoverage | null>(null);
|
const [coverage, setCoverage] = useState<SessionCoverage | null>(null);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@@ -301,7 +303,7 @@ export function CoverageRing({ chatId, refreshKey = 0 }: { chatId: string; refre
|
|||||||
<div ref={rootRef} className="lh-no-drag relative shrink-0">
|
<div ref={rootRef} className="lh-no-drag relative shrink-0">
|
||||||
<Hint
|
<Hint
|
||||||
// Trimmed (owner, subtlety round 2026-09-25).
|
// Trimmed (owner, subtlety round 2026-09-25).
|
||||||
label="How much of this session isn't mapped yet"
|
label={t("How much of this session isn't mapped yet")}
|
||||||
side="bottom"
|
side="bottom"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
@@ -359,46 +361,43 @@ export function CoverageRingGlyph({ fill, running }: { fill: number; running: bo
|
|||||||
* covered/uncovered tokens, message coverage, the threshold, the last
|
* covered/uncovered tokens, message coverage, the threshold, the last
|
||||||
* fold's age. */
|
* fold's age. */
|
||||||
export function CoverageRingDetail({ coverage, chunkTokens }: { coverage: SessionCoverage; chunkTokens: number }) {
|
export function CoverageRingDetail({ coverage, chunkTokens }: { coverage: SessionCoverage; chunkTokens: number }) {
|
||||||
|
const { t, language } = useI18n();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
<div className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
Extraction coverage
|
{t("Extraction coverage")}</div>
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1 text-muted-foreground">
|
<div className="flex flex-col gap-1 text-muted-foreground">
|
||||||
<div className="flex justify-between gap-2">
|
<div className="flex justify-between gap-2">
|
||||||
<span>Uncovered</span>
|
<span>{t("Uncovered")}</span>
|
||||||
<span className="tabular-nums text-card-foreground">
|
<span className="tabular-nums text-card-foreground">
|
||||||
{formatTokens(coverage.uncoveredTokens)} tokens
|
{formatTokens(coverage.uncoveredTokens, language)}{" "}{t("tokens")}</span>
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between gap-2">
|
<div className="flex justify-between gap-2">
|
||||||
<span>Covered</span>
|
<span>{t("Covered")}</span>
|
||||||
<span className="tabular-nums text-card-foreground">
|
<span className="tabular-nums text-card-foreground">
|
||||||
{formatTokens(coverage.coveredTokens)} tokens
|
{formatTokens(coverage.coveredTokens, language)}{" "}{t("tokens")}</span>
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between gap-2">
|
<div className="flex justify-between gap-2">
|
||||||
<span>Messages</span>
|
<span>{t("Messages")}</span>
|
||||||
<span className="tabular-nums text-card-foreground">
|
<span className="tabular-nums text-card-foreground">
|
||||||
{coverage.coveredCount} / {coverage.totalMessages}
|
{coverage.coveredCount} / {coverage.totalMessages}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between gap-2">
|
<div className="flex justify-between gap-2">
|
||||||
<span>Chunk threshold</span>
|
<span>{t("Chunk threshold")}</span>
|
||||||
<span className="tabular-nums text-card-foreground">
|
<span className="tabular-nums text-card-foreground">
|
||||||
{formatTokens(chunkTokens)} tokens
|
{formatTokens(chunkTokens, language)}{" "}{t("tokens")}</span>
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between gap-2">
|
<div className="flex justify-between gap-2">
|
||||||
<span>Last extraction</span>
|
<span>{t("Last extraction")}</span>
|
||||||
<span className="tabular-nums text-card-foreground">
|
<span className="tabular-nums text-card-foreground">
|
||||||
{coverage.lastFoldedAt ? timeAgo(coverage.lastFoldedAt) : "—"}
|
{coverage.lastFoldedAt ? timeAgo(coverage.lastFoldedAt) : "—"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{coverage.running && (
|
{coverage.running && (
|
||||||
<div className="flex justify-between gap-2" data-testid="coverage-ring-running">
|
<div className="flex justify-between gap-2" data-testid="coverage-ring-running">
|
||||||
<span>Status</span>
|
<span>{t("Status")}</span>
|
||||||
<span className="text-card-foreground">extracting…</span>
|
<span className="text-card-foreground">{t("extracting…")}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { countLabel } from "@/src/core";
|
||||||
|
import { useI18n } from "@/components/i18n";
|
||||||
|
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { ChevronRightIcon } from "lucide-react";
|
import { ChevronRightIcon } from "lucide-react";
|
||||||
@@ -21,9 +24,10 @@ export interface GraphContextData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function GraphContextCard({ data }: { data: GraphContextData }) {
|
export function GraphContextCard({ data }: { data: GraphContextData }) {
|
||||||
|
const { t, language } = useI18n();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const { propositions = [], chains = [] } = data ?? {};
|
const { propositions = [], chains = [] } = data ?? {};
|
||||||
const noun = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`;
|
const noun = (n: number, one: string, many: string) => countLabel(language, n, one, many);
|
||||||
return (
|
return (
|
||||||
<div data-testid="graph-context-card" className="my-1">
|
<div data-testid="graph-context-card" className="my-1">
|
||||||
<button
|
<button
|
||||||
@@ -33,7 +37,7 @@ export function GraphContextCard({ data }: { data: GraphContextData }) {
|
|||||||
className="flex w-full cursor-pointer items-center gap-2 rounded-lg border border-dashed border-border/70 px-3 py-2 text-left text-xs text-muted-foreground transition-colors hover:text-foreground"
|
className="flex w-full cursor-pointer items-center gap-2 rounded-lg border border-dashed border-border/70 px-3 py-2 text-left text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||||
>
|
>
|
||||||
<ChevronRightIcon aria-hidden="true" className={cn("size-3.5 shrink-0 transition-transform", open && "rotate-90")} />
|
<ChevronRightIcon aria-hidden="true" className={cn("size-3.5 shrink-0 transition-transform", open && "rotate-90")} />
|
||||||
<span className="font-medium">Graph context</span>
|
<span className="font-medium">{t("Graph context")}</span>
|
||||||
<span className="min-w-0 flex-1 truncate">
|
<span className="min-w-0 flex-1 truncate">
|
||||||
{noun(propositions.length, "proposition", "propositions")}
|
{noun(propositions.length, "proposition", "propositions")}
|
||||||
{chains.length > 0 ? ` · ${noun(chains.length, "chain", "chains")}` : ""}
|
{chains.length > 0 ? ` · ${noun(chains.length, "chain", "chains")}` : ""}
|
||||||
|
|||||||
+88
-75
@@ -1,4 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { translator, countLabel, type Language } from "@/src/core";
|
||||||
|
import { useI18n } from "@/components/i18n";
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The home knowledge-graph view (W4; vision §1 "Rendering (DECIDED)").
|
* The home knowledge-graph view (W4; vision §1 "Rendering (DECIDED)").
|
||||||
@@ -68,6 +71,7 @@ import { NotesImportErrors, useNotesImport } from "@/components/notes-import.tsx
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { FileTextIcon, ListTreeIcon, Loader2Icon, SearchIcon, SettingsIcon, UploadIcon, WaypointsIcon } from "lucide-react";
|
import { FileTextIcon, ListTreeIcon, Loader2Icon, SearchIcon, SettingsIcon, UploadIcon, WaypointsIcon } from "lucide-react";
|
||||||
import { incrementalLayout, propositionSessionHref, type GraphCostSummary, type GraphNode, type GraphSnapshot } from "@/src/core";
|
import { incrementalLayout, propositionSessionHref, type GraphCostSummary, type GraphNode, type GraphSnapshot } from "@/src/core";
|
||||||
|
import { planCostLabel } from "@/components/usage-format";
|
||||||
import { GraphCanvasRenderer, type GraphRenderSettings } from "./graph-canvas.ts";
|
import { GraphCanvasRenderer, type GraphRenderSettings } from "./graph-canvas.ts";
|
||||||
import { usePopoverDismiss } from "@/components/popover-dismiss";
|
import { usePopoverDismiss } from "@/components/popover-dismiss";
|
||||||
import { useActivity } from "@/components/activity-dock";
|
import { useActivity } from "@/components/activity-dock";
|
||||||
@@ -275,7 +279,8 @@ function SettingSlider({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Compact token display: 45231 → "45.2k"; absent → "—". */
|
/** Compact token display: 45231 → "45.2k"; absent → "—". */
|
||||||
function fmtTokens(n: number | undefined): string {
|
function fmtTokens(n: number | undefined, language: Language = "en"): string {
|
||||||
|
if (language === "ru" && n !== undefined) return new Intl.NumberFormat("ru-RU", { notation: "compact", maximumFractionDigits: 1 }).format(n);
|
||||||
if (n === undefined) return "—";
|
if (n === undefined) return "—";
|
||||||
if (n < 1000) return String(Math.round(n));
|
if (n < 1000) return String(Math.round(n));
|
||||||
if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`;
|
if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`;
|
||||||
@@ -283,19 +288,30 @@ function fmtTokens(n: number | undefined): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Relative time for run rows: "now", "12m", "3h", "2d". */
|
/** Relative time for run rows: "now", "12m", "3h", "2d". */
|
||||||
function timeAgo(startedAt: number): string {
|
function timeAgo(startedAt: number, language: Language = "en"): string {
|
||||||
|
const t = translator(language);
|
||||||
const mins = Math.round((Date.now() - startedAt) / 60_000);
|
const mins = Math.round((Date.now() - startedAt) / 60_000);
|
||||||
if (mins < 1) return "now";
|
if (mins < 1) return t("now");
|
||||||
if (mins < 60) return `${mins}m`;
|
if (mins < 60) return t("{v0}m", { v0: mins });
|
||||||
const hours = Math.round(mins / 60);
|
const hours = Math.round(mins / 60);
|
||||||
if (hours < 24) return `${hours}h`;
|
if (hours < 24) return t("{v0}h", { v0: hours });
|
||||||
return `${Math.round(hours / 24)}d`;
|
return t("{v0}d", { v0: Math.round(hours / 24) });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Run/stage cost at 3 decimals (pipeline pennies are real money at this
|
/** Run/stage cost at 3 decimals (pipeline pennies are real money at this
|
||||||
* scale); headline totals use 2. Null (unpriced model) renders "—". */
|
* scale); headline totals use 2. Null (unpriced model) renders "—". */
|
||||||
function fmtRunCost(dollars: number | null): string {
|
function fmtRunCost(dollars: number | null, providers: readonly string[] = [], language: Language = "en"): string {
|
||||||
return dollars === null ? "—" : `$${dollars.toFixed(3)}`;
|
return planCostLabel(dollars, providers, (value) => language === "ru" ? `${value.toLocaleString("ru-RU", { minimumFractionDigits: 3, maximumFractionDigits: 3 })} $` : `$${value.toFixed(3)}`);
|
||||||
|
}
|
||||||
|
function graphProviders(value: { subscriptionRuns?: number; subscriptionProviders?: string[] }): string[] {
|
||||||
|
return value.subscriptionProviders ?? ((value.subscriptionRuns ?? 0) > 0 ? ["ChatGPT"] : []);
|
||||||
|
}
|
||||||
|
function graphUsageLabel(costs: Pick<GraphCostSummary, "stages">, language: Language = "en"): string {
|
||||||
|
const stages = [costs.stages.extraction, costs.stages.audit];
|
||||||
|
const providers = [...new Set(stages.flatMap(graphProviders))];
|
||||||
|
const priced = stages.some((stage) => stage.runs - stage.unpricedRuns - (stage.subscriptionRuns ?? 0) > 0);
|
||||||
|
const dollars = displayCostTotals(costs).totalDollars;
|
||||||
|
return planCostLabel(priced ? dollars : null, providers, (value) => language === "ru" ? `${value.toLocaleString("ru-RU", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} $` : `$${value.toFixed(2)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -329,6 +345,7 @@ export function displayCostTotals(costs: Pick<GraphCostSummary, "stages">): {
|
|||||||
/** Exported for the chains home (graph rendering off, owner
|
/** Exported for the chains home (graph rendering off, owner
|
||||||
* 2026-10-06): the same bare cumulative-dollars pill + panel. */
|
* 2026-10-06): the same bare cumulative-dollars pill + panel. */
|
||||||
export function GraphCostControl({ costs }: { costs: GraphCostSummary }) {
|
export function GraphCostControl({ costs }: { costs: GraphCostSummary }) {
|
||||||
|
const { t, language } = useI18n();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
// Click anywhere outside (or Escape) closes the panel — the root
|
// Click anywhere outside (or Escape) closes the panel — the root
|
||||||
// wraps button + panel, so the toggle itself keeps working.
|
// wraps button + panel, so the toggle itself keeps working.
|
||||||
@@ -338,7 +355,7 @@ export function GraphCostControl({ costs }: { costs: GraphCostSummary }) {
|
|||||||
<div ref={rootRef} className="relative">
|
<div ref={rootRef} className="relative">
|
||||||
<Hint
|
<Hint
|
||||||
// Trimmed (owner, subtlety round 2026-09-25).
|
// Trimmed (owner, subtlety round 2026-09-25).
|
||||||
label="Graph costs — click for details"
|
label={t("Graph costs — click for details")}
|
||||||
side="bottom"
|
side="bottom"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
@@ -349,7 +366,7 @@ export function GraphCostControl({ costs }: { costs: GraphCostSummary }) {
|
|||||||
open ? "text-foreground" : "text-muted-foreground hover:text-foreground"
|
open ? "text-foreground" : "text-muted-foreground hover:text-foreground"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
${displayCostTotals(costs).totalDollars.toFixed(2)}
|
{graphUsageLabel(costs, language)}
|
||||||
</button>
|
</button>
|
||||||
</Hint>
|
</Hint>
|
||||||
{open && (
|
{open && (
|
||||||
@@ -372,23 +389,23 @@ export function GraphCostControl({ costs }: { costs: GraphCostSummary }) {
|
|||||||
* naming-kind runs never reach the list.
|
* naming-kind runs never reach the list.
|
||||||
*/
|
*/
|
||||||
function GraphCostPanel({ costs }: { costs: GraphCostSummary }) {
|
function GraphCostPanel({ costs }: { costs: GraphCostSummary }) {
|
||||||
|
const { t, language } = useI18n();
|
||||||
const stageRows: [string, GraphCostSummary["stages"]["extraction"]][] = [
|
const stageRows: [string, GraphCostSummary["stages"]["extraction"]][] = [
|
||||||
["Extraction", costs.stages.extraction],
|
[t("Extraction"), costs.stages.extraction],
|
||||||
["Audit", costs.stages.audit],
|
[t("Audit"), costs.stages.audit],
|
||||||
];
|
];
|
||||||
// The displayed figures come from displayCostTotals (naming excluded —
|
// The displayed figures come from displayCostTotals (naming excluded —
|
||||||
// the pill and this panel must agree).
|
// the pill and this panel must agree).
|
||||||
const { totalDollars, unpricedRuns } = displayCostTotals(costs);
|
const { unpricedRuns } = displayCostTotals(costs);
|
||||||
return (
|
return (
|
||||||
<div className="w-80 max-w-[calc(100vw-1.5rem)] rounded-xl border bg-card/95 p-3 text-xs shadow-lg backdrop-blur">
|
<div className="w-80 max-w-[calc(100vw-1.5rem)] rounded-xl border bg-card/95 p-3 text-xs shadow-lg backdrop-blur">
|
||||||
<div className="mb-2 flex items-baseline justify-between">
|
<div className="mb-2 flex items-baseline justify-between">
|
||||||
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
Graph pipeline costs
|
{t("Graph pipeline costs")}</span>
|
||||||
</span>
|
|
||||||
<span className="text-foreground tabular-nums">
|
<span className="text-foreground tabular-nums">
|
||||||
${totalDollars.toFixed(2)}
|
{graphUsageLabel(costs, language)}
|
||||||
{unpricedRuns > 0 && (
|
{unpricedRuns > 0 && (
|
||||||
<span className="text-muted-foreground"> · {unpricedRuns} unpriced</span>
|
<span className="text-muted-foreground"> · {unpricedRuns}{" "}{t("unpriced")}</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -397,14 +414,13 @@ function GraphCostPanel({ costs }: { costs: GraphCostSummary }) {
|
|||||||
<div key={label} className="flex items-baseline justify-between gap-2 text-[11px]">
|
<div key={label} className="flex items-baseline justify-between gap-2 text-[11px]">
|
||||||
<span className="shrink-0 text-muted-foreground">{label}</span>
|
<span className="shrink-0 text-muted-foreground">{label}</span>
|
||||||
<span className="min-w-0 text-right tabular-nums">
|
<span className="min-w-0 text-right tabular-nums">
|
||||||
{fmtRunCost(stage.runs === 0 || (stage.unpricedRuns > 0 && stage.dollars === 0) ? null : stage.dollars)}
|
{fmtRunCost(stage.runs - stage.unpricedRuns - (stage.subscriptionRuns ?? 0) <= 0 ? null : stage.dollars, graphProviders(stage), language)}
|
||||||
{stage.unpricedRuns > 0 && stage.dollars > 0 && (
|
{stage.unpricedRuns > 0 && (stage.runs - stage.unpricedRuns - (stage.subscriptionRuns ?? 0) > 0 || (stage.subscriptionRuns ?? 0) > 0) && (
|
||||||
<span className="text-muted-foreground"> +{stage.unpricedRuns} unpriced</span>
|
<span className="text-muted-foreground"> +{stage.unpricedRuns}{" "}{t("unpriced")}</span>
|
||||||
)}
|
)}
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{" "}
|
{" "}
|
||||||
· {fmtTokens(stage.tokens.inputTokens)} in / {fmtTokens(stage.tokens.outputTokens)} out
|
· {fmtTokens(stage.tokens.inputTokens, language)}{" "}{t("in /")}{" "}{fmtTokens(stage.tokens.outputTokens, language)}{" "}{t("out")}</span>
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -413,16 +429,16 @@ function GraphCostPanel({ costs }: { costs: GraphCostSummary }) {
|
|||||||
{costs.runs.map((run) => (
|
{costs.runs.map((run) => (
|
||||||
<div key={run.startedAt} className="flex items-baseline justify-between gap-2 text-[11px]">
|
<div key={run.startedAt} className="flex items-baseline justify-between gap-2 text-[11px]">
|
||||||
<span className="min-w-0 flex-1 truncate text-muted-foreground">
|
<span className="min-w-0 flex-1 truncate text-muted-foreground">
|
||||||
<span className="text-foreground/70">{timeAgo(run.startedAt)}</span>{" "}
|
<span className="text-foreground/70">{timeAgo(run.startedAt, language)}</span>{" "}
|
||||||
{run.title}
|
{run.title}
|
||||||
{run.merged && (
|
{run.merged && (
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{" "}
|
{" "}
|
||||||
+{run.merged.nodes}n
|
+{language === "ru" ? countLabel(language, run.merged.nodes, "node", "nodes") : `${run.merged.nodes}n`}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="shrink-0 tabular-nums">{fmtRunCost(run.dollars)}</span>
|
<span className="shrink-0 tabular-nums">{fmtRunCost(run.dollars, graphProviders(run), language)}{run.dollars === null && run.subscriptionRuns === 1 ? t(" +1 unpriced") : ""}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -443,6 +459,7 @@ function GraphSettingsControl({
|
|||||||
settings: GraphRenderSettings;
|
settings: GraphRenderSettings;
|
||||||
changeSettings: (update: (cur: RenderSettings) => RenderSettings) => void;
|
changeSettings: (update: (cur: RenderSettings) => RenderSettings) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
// Click anywhere outside (or Escape) closes the panel (2026-09-17) —
|
// Click anywhere outside (or Escape) closes the panel (2026-09-17) —
|
||||||
// the root wraps button + panel, so the toggle itself keeps working.
|
// the root wraps button + panel, so the toggle itself keeps working.
|
||||||
@@ -454,7 +471,7 @@ function GraphSettingsControl({
|
|||||||
<div ref={rootRef} className="relative">
|
<div ref={rootRef} className="relative">
|
||||||
<Hint
|
<Hint
|
||||||
// User language (tooltip wave) — "rendering" is internal.
|
// User language (tooltip wave) — "rendering" is internal.
|
||||||
label="Graph display settings"
|
label={t("Graph display settings")}
|
||||||
side="bottom"
|
side="bottom"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
@@ -487,7 +504,7 @@ function GraphSettingsControl({
|
|||||||
number — src/core/render-settings.ts). Physics keeps the
|
number — src/core/render-settings.ts). Physics keeps the
|
||||||
fine-grained log sliders (step 5). */}
|
fine-grained log sliders (step 5). */}
|
||||||
<SettingSlider
|
<SettingSlider
|
||||||
label="Node size"
|
label={t("Node size")}
|
||||||
min={0}
|
min={0}
|
||||||
max={100}
|
max={100}
|
||||||
step={10}
|
step={10}
|
||||||
@@ -497,7 +514,7 @@ function GraphSettingsControl({
|
|||||||
changeSettings((cur) => ({ ...cur, nodeSize: fromLadderPosition(p, NODE_SIZE_LADDER) }))}
|
changeSettings((cur) => ({ ...cur, nodeSize: fromLadderPosition(p, NODE_SIZE_LADDER) }))}
|
||||||
/>
|
/>
|
||||||
<SettingSlider
|
<SettingSlider
|
||||||
label="Edge width"
|
label={t("Edge width")}
|
||||||
min={0}
|
min={0}
|
||||||
max={100}
|
max={100}
|
||||||
step={10}
|
step={10}
|
||||||
@@ -507,7 +524,7 @@ function GraphSettingsControl({
|
|||||||
changeSettings((cur) => ({ ...cur, edgeWidth: fromLadderPosition(p, EDGE_WIDTH_LADDER) }))}
|
changeSettings((cur) => ({ ...cur, edgeWidth: fromLadderPosition(p, EDGE_WIDTH_LADDER) }))}
|
||||||
/>
|
/>
|
||||||
<SettingSlider
|
<SettingSlider
|
||||||
label="Arrowhead size"
|
label={t("Arrowhead size")}
|
||||||
min={0}
|
min={0}
|
||||||
max={100}
|
max={100}
|
||||||
step={10}
|
step={10}
|
||||||
@@ -517,7 +534,7 @@ function GraphSettingsControl({
|
|||||||
changeSettings((cur) => ({ ...cur, arrowLength: fromLadderPosition(p, ARROW_LENGTH_LADDER) }))}
|
changeSettings((cur) => ({ ...cur, arrowLength: fromLadderPosition(p, ARROW_LENGTH_LADDER) }))}
|
||||||
/>
|
/>
|
||||||
<SettingSlider
|
<SettingSlider
|
||||||
label="Label size"
|
label={t("Label size")}
|
||||||
min={0}
|
min={0}
|
||||||
max={100}
|
max={100}
|
||||||
step={10}
|
step={10}
|
||||||
@@ -528,7 +545,7 @@ function GraphSettingsControl({
|
|||||||
/>
|
/>
|
||||||
{/* Display is absolute (ratio / 0.5): "1×" is the default, dead-center (owner, 2026-10-07). */}
|
{/* Display is absolute (ratio / 0.5): "1×" is the default, dead-center (owner, 2026-10-07). */}
|
||||||
<SettingSlider
|
<SettingSlider
|
||||||
label="Labels until zoom"
|
label={t("Labels until zoom")}
|
||||||
min={0}
|
min={0}
|
||||||
max={100}
|
max={100}
|
||||||
step={10}
|
step={10}
|
||||||
@@ -546,10 +563,9 @@ function GraphSettingsControl({
|
|||||||
the paint. */}
|
the paint. */}
|
||||||
<div className="mt-1.5 flex flex-col gap-1.5 border-t pt-1.5">
|
<div className="mt-1.5 flex flex-col gap-1.5 border-t pt-1.5">
|
||||||
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
Regions
|
{t("Regions")}</span>
|
||||||
</span>
|
|
||||||
<label className="flex cursor-pointer items-center justify-between text-[11px] text-muted-foreground">
|
<label className="flex cursor-pointer items-center justify-between text-[11px] text-muted-foreground">
|
||||||
<span>Background colors</span>
|
<span>{t("Background colors")}</span>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={settings.regions.enabled}
|
checked={settings.regions.enabled}
|
||||||
@@ -562,7 +578,7 @@ function GraphSettingsControl({
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<SettingSlider
|
<SettingSlider
|
||||||
label="Dilation"
|
label={t("Dilation")}
|
||||||
disabled={!regionTuning.enabled}
|
disabled={!regionTuning.enabled}
|
||||||
min={0}
|
min={0}
|
||||||
max={100}
|
max={100}
|
||||||
@@ -576,7 +592,7 @@ function GraphSettingsControl({
|
|||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
<SettingSlider
|
<SettingSlider
|
||||||
label="Blur"
|
label={t("Blur")}
|
||||||
disabled={!regionTuning.enabled}
|
disabled={!regionTuning.enabled}
|
||||||
min={0}
|
min={0}
|
||||||
max={100}
|
max={100}
|
||||||
@@ -591,7 +607,7 @@ function GraphSettingsControl({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<SettingSlider
|
<SettingSlider
|
||||||
label="Edge alpha"
|
label={t("Edge alpha")}
|
||||||
disabled={!regionTuning.enabled}
|
disabled={!regionTuning.enabled}
|
||||||
min={0}
|
min={0}
|
||||||
max={100}
|
max={100}
|
||||||
@@ -607,7 +623,7 @@ function GraphSettingsControl({
|
|||||||
<label
|
<label
|
||||||
className={`flex items-center justify-between gap-2 text-[11px] text-muted-foreground${!regionTuning.enabled ? " opacity-40" : ""}`}
|
className={`flex items-center justify-between gap-2 text-[11px] text-muted-foreground${!regionTuning.enabled ? " opacity-40" : ""}`}
|
||||||
>
|
>
|
||||||
<span>Bitmap resolution</span>
|
<span>{t("Bitmap resolution")}</span>
|
||||||
<select
|
<select
|
||||||
disabled={!regionTuning.enabled}
|
disabled={!regionTuning.enabled}
|
||||||
className="rounded-md border bg-background px-1.5 py-0.5 text-xs tabular-nums text-foreground"
|
className="rounded-md border bg-background px-1.5 py-0.5 text-xs tabular-nums text-foreground"
|
||||||
@@ -632,7 +648,7 @@ function GraphSettingsControl({
|
|||||||
their own clusterLabels toggle. Size is screen px
|
their own clusterLabels toggle. Size is screen px
|
||||||
(constant at any zoom, unlike node labels). */}
|
(constant at any zoom, unlike node labels). */}
|
||||||
<label className="flex cursor-pointer items-center justify-between text-[11px] text-muted-foreground">
|
<label className="flex cursor-pointer items-center justify-between text-[11px] text-muted-foreground">
|
||||||
<span>Cluster labels</span>
|
<span>{t("Cluster labels")}</span>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={settings.clusterLabels}
|
checked={settings.clusterLabels}
|
||||||
@@ -641,7 +657,7 @@ function GraphSettingsControl({
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<SettingSlider
|
<SettingSlider
|
||||||
label="Cluster label size"
|
label={t("Cluster label size")}
|
||||||
disabled={!settings.clusterLabels}
|
disabled={!settings.clusterLabels}
|
||||||
min={0}
|
min={0}
|
||||||
max={100}
|
max={100}
|
||||||
@@ -667,10 +683,9 @@ function GraphSettingsControl({
|
|||||||
anchor + its gray-out-able controls last. */}
|
anchor + its gray-out-able controls last. */}
|
||||||
<div className="mt-1.5 flex flex-col gap-1.5 border-t pt-1.5">
|
<div className="mt-1.5 flex flex-col gap-1.5 border-t pt-1.5">
|
||||||
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
Physics
|
{t("Physics")}</span>
|
||||||
</span>
|
|
||||||
<SettingSlider
|
<SettingSlider
|
||||||
label="Node repulsion"
|
label={t("Node repulsion")}
|
||||||
min={0}
|
min={0}
|
||||||
max={100}
|
max={100}
|
||||||
step={5}
|
step={5}
|
||||||
@@ -683,7 +698,7 @@ function GraphSettingsControl({
|
|||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
<SettingSlider
|
<SettingSlider
|
||||||
label="Edge attraction"
|
label={t("Edge attraction")}
|
||||||
min={0}
|
min={0}
|
||||||
max={100}
|
max={100}
|
||||||
step={5}
|
step={5}
|
||||||
@@ -696,7 +711,7 @@ function GraphSettingsControl({
|
|||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
<SettingSlider
|
<SettingSlider
|
||||||
label="Edge length"
|
label={t("Edge length")}
|
||||||
min={0}
|
min={0}
|
||||||
max={100}
|
max={100}
|
||||||
step={5}
|
step={5}
|
||||||
@@ -709,7 +724,7 @@ function GraphSettingsControl({
|
|||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
<SettingSlider
|
<SettingSlider
|
||||||
label="Central pull"
|
label={t("Central pull")}
|
||||||
min={0}
|
min={0}
|
||||||
max={100}
|
max={100}
|
||||||
step={5}
|
step={5}
|
||||||
@@ -722,12 +737,12 @@ function GraphSettingsControl({
|
|||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
<SettingSlider
|
<SettingSlider
|
||||||
label="Cluster pull"
|
label={t("Cluster pull")}
|
||||||
min={0}
|
min={0}
|
||||||
max={100}
|
max={100}
|
||||||
step={5}
|
step={5}
|
||||||
value={toOffSliderPosition(physicsTuning.clusterPull, CLUSTER_PULL_RANGE.min, CLUSTER_PULL_RANGE.max)}
|
value={toOffSliderPosition(physicsTuning.clusterPull, CLUSTER_PULL_RANGE.min, CLUSTER_PULL_RANGE.max)}
|
||||||
display={physicsTuning.clusterPull <= 0 ? "off" : sizeDisplay(physicsTuning.clusterPull)}
|
display={physicsTuning.clusterPull <= 0 ? t("off") : sizeDisplay(physicsTuning.clusterPull)}
|
||||||
onChange={(p) =>
|
onChange={(p) =>
|
||||||
changeSettings((cur) => ({
|
changeSettings((cur) => ({
|
||||||
...cur,
|
...cur,
|
||||||
@@ -735,7 +750,7 @@ function GraphSettingsControl({
|
|||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
<SettingSlider
|
<SettingSlider
|
||||||
label="Friction"
|
label={t("Friction")}
|
||||||
min={0}
|
min={0}
|
||||||
max={100}
|
max={100}
|
||||||
step={5}
|
step={5}
|
||||||
@@ -752,8 +767,7 @@ function GraphSettingsControl({
|
|||||||
controls at the very bottom — the grayed group reads
|
controls at the very bottom — the grayed group reads
|
||||||
as a self-contained sub-section. */}
|
as a self-contained sub-section. */}
|
||||||
<label className="flex items-center justify-between gap-2 text-xs">
|
<label className="flex items-center justify-between gap-2 text-xs">
|
||||||
Depth anchor
|
{t("Depth anchor")}<select
|
||||||
<select
|
|
||||||
className="rounded border bg-background px-1 py-0.5 text-xs"
|
className="rounded border bg-background px-1 py-0.5 text-xs"
|
||||||
value={physicsTuning.depthMode}
|
value={physicsTuning.depthMode}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
@@ -763,13 +777,13 @@ function GraphSettingsControl({
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<option value="off">Off</option>
|
<option value="off">{t("Off")}</option>
|
||||||
<option value="vertical">Vertical (up)</option>
|
<option value="vertical">{t("Vertical (up)")}</option>
|
||||||
<option value="radial">Radial (outward)</option>
|
<option value="radial">{t("Radial (outward)")}</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<SettingSlider
|
<SettingSlider
|
||||||
label="Depth strength"
|
label={t("Depth strength")}
|
||||||
disabled={physicsTuning.depthMode === "off"}
|
disabled={physicsTuning.depthMode === "off"}
|
||||||
min={0}
|
min={0}
|
||||||
max={100}
|
max={100}
|
||||||
@@ -783,7 +797,7 @@ function GraphSettingsControl({
|
|||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
<SettingSlider
|
<SettingSlider
|
||||||
label="Depth spacing"
|
label={t("Depth spacing")}
|
||||||
disabled={physicsTuning.depthMode === "off"}
|
disabled={physicsTuning.depthMode === "off"}
|
||||||
min={0}
|
min={0}
|
||||||
max={100}
|
max={100}
|
||||||
@@ -797,7 +811,7 @@ function GraphSettingsControl({
|
|||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
<SettingSlider
|
<SettingSlider
|
||||||
label="Base ring radius"
|
label={t("Base ring radius")}
|
||||||
disabled={physicsTuning.depthMode !== "radial"}
|
disabled={physicsTuning.depthMode !== "radial"}
|
||||||
min={0}
|
min={0}
|
||||||
max={100}
|
max={100}
|
||||||
@@ -855,13 +869,14 @@ export function GraphChromeRow({
|
|||||||
* 2026-09-29). */
|
* 2026-09-29). */
|
||||||
onOpenChains: () => void;
|
onOpenChains: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{!isEmpty && (
|
{!isEmpty && (
|
||||||
<div className="graph-chrome-fade-in w-56 min-w-0 shrink rounded-xl border bg-card/85 px-1 py-[3px] shadow-lg backdrop-blur">
|
<div className="graph-chrome-fade-in w-56 min-w-0 shrink rounded-xl border bg-card/85 px-1 py-[3px] shadow-lg backdrop-blur">
|
||||||
<Hint
|
<Hint
|
||||||
// ⌘F focuses node search (surface-scoped registry action).
|
// ⌘F focuses node search (surface-scoped registry action).
|
||||||
label="Search nodes"
|
label={t("Search nodes")}
|
||||||
action="find"
|
action="find"
|
||||||
side="bottom"
|
side="bottom"
|
||||||
>
|
>
|
||||||
@@ -871,15 +886,15 @@ export function GraphChromeRow({
|
|||||||
ref={searchInputRef}
|
ref={searchInputRef}
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => onQuery(e.target.value)}
|
onChange={(e) => onQuery(e.target.value)}
|
||||||
placeholder="Search nodes…"
|
placeholder={t("Search nodes…")}
|
||||||
aria-label="Search nodes"
|
aria-label={t("Search nodes")}
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
className="h-7 w-full rounded-lg border bg-background pl-8 text-xs outline-none placeholder:text-muted-foreground focus:border-ring"
|
className="h-7 w-full rounded-lg border bg-background pl-8 text-xs outline-none placeholder:text-muted-foreground focus:border-ring"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Hint>
|
</Hint>
|
||||||
{noMatch && (
|
{noMatch && (
|
||||||
<div className="px-1.5 pt-1 text-[11px] text-muted-foreground">No matching nodes.</div>
|
<div className="px-1.5 pt-1 text-[11px] text-muted-foreground">{t("No matching nodes.")}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -887,7 +902,7 @@ export function GraphChromeRow({
|
|||||||
container (36px band), between the search and Import. */}
|
container (36px band), between the search and Import. */}
|
||||||
{!isEmpty && (
|
{!isEmpty && (
|
||||||
<div className="graph-chrome-fade-in shrink-0 rounded-xl border bg-card/85 px-1 py-[3px] shadow-lg backdrop-blur">
|
<div className="graph-chrome-fade-in shrink-0 rounded-xl border bg-card/85 px-1 py-[3px] shadow-lg backdrop-blur">
|
||||||
<Hint label="Every reasoning chain" side="bottom">
|
<Hint label={t("Every reasoning chain")} side="bottom">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onOpenChains}
|
onClick={onOpenChains}
|
||||||
@@ -895,8 +910,7 @@ export function GraphChromeRow({
|
|||||||
className="flex h-7 items-center gap-1.5 rounded-lg px-2.5 text-xs text-muted-foreground transition-colors hover:bg-accent"
|
className="flex h-7 items-center gap-1.5 rounded-lg px-2.5 text-xs text-muted-foreground transition-colors hover:bg-accent"
|
||||||
>
|
>
|
||||||
<ListTreeIcon className="size-3" />
|
<ListTreeIcon className="size-3" />
|
||||||
Chains
|
{t("Chains")}</button>
|
||||||
</button>
|
|
||||||
</Hint>
|
</Hint>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -918,7 +932,7 @@ export function GraphChromeRow({
|
|||||||
// The one button whose action isn't obvious from its label:
|
// The one button whose action isn't obvious from its label:
|
||||||
// the missing fact is where imports go (owner, subtlety
|
// the missing fact is where imports go (owner, subtlety
|
||||||
// round 2026-09-25).
|
// round 2026-09-25).
|
||||||
label="Import notes into the graph"
|
label={t("Import notes into the graph")}
|
||||||
side="bottom"
|
side="bottom"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
@@ -929,7 +943,7 @@ export function GraphChromeRow({
|
|||||||
className="flex h-7 items-center gap-1.5 rounded-lg px-2.5 text-xs text-muted-foreground transition-colors hover:bg-accent disabled:opacity-50"
|
className="flex h-7 items-center gap-1.5 rounded-lg px-2.5 text-xs text-muted-foreground transition-colors hover:bg-accent disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<UploadIcon className="size-3" />
|
<UploadIcon className="size-3" />
|
||||||
{importing ? "Importing…" : "Import notes…"}
|
{importing ? t("Importing…") : t("Import notes…")}
|
||||||
</button>
|
</button>
|
||||||
</Hint>
|
</Hint>
|
||||||
</div>
|
</div>
|
||||||
@@ -963,6 +977,7 @@ export function GraphView({
|
|||||||
onPracticeSelectionChange?: (nodes: { id: string; name: string; statement: string }[]) => void;
|
onPracticeSelectionChange?: (nodes: { id: string; name: string; statement: string }[]) => void;
|
||||||
onChainSelectClose?: () => void;
|
onChainSelectClose?: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
const cardRef = useRef<HTMLDivElement | null>(null);
|
const cardRef = useRef<HTMLDivElement | null>(null);
|
||||||
@@ -1487,10 +1502,9 @@ export function GraphView({
|
|||||||
<div className="flex h-full w-full items-center justify-center px-4">
|
<div className="flex h-full w-full items-center justify-center px-4">
|
||||||
<div className="flex flex-col items-center gap-3 text-center text-muted-foreground">
|
<div className="flex flex-col items-center gap-3 text-center text-muted-foreground">
|
||||||
<WaypointsIcon className="size-10 opacity-40" strokeWidth={1.25} />
|
<WaypointsIcon className="size-10 opacity-40" strokeWidth={1.25} />
|
||||||
<div className="text-lg font-medium text-foreground">Knowledge graph</div>
|
<div className="text-lg font-medium text-foreground">{t("Knowledge graph")}</div>
|
||||||
<p className="max-w-sm text-sm">
|
<p className="max-w-sm text-sm">
|
||||||
Start a session or import notes to build your graph.
|
{t("Start a session or import notes to build your graph.")}</p>
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -1530,8 +1544,7 @@ export function GraphView({
|
|||||||
}}
|
}}
|
||||||
className="flex items-center rounded-lg px-2.5 py-1.5 text-left text-muted-foreground/80 transition-colors hover:bg-accent hover:text-foreground"
|
className="flex items-center rounded-lg px-2.5 py-1.5 text-left text-muted-foreground/80 transition-colors hover:bg-accent hover:text-foreground"
|
||||||
>
|
>
|
||||||
Delete
|
{t("Delete")}</button>
|
||||||
</button>
|
|
||||||
</MenuShell>
|
</MenuShell>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1619,9 +1632,9 @@ export function GraphView({
|
|||||||
className="flex items-center gap-2 rounded-full border bg-card/85 px-3 py-1.5 text-xs text-muted-foreground shadow-lg backdrop-blur"
|
className="flex items-center gap-2 rounded-full border bg-card/85 px-3 py-1.5 text-xs text-muted-foreground shadow-lg backdrop-blur"
|
||||||
>
|
>
|
||||||
<Loader2Icon className="size-3.5 shrink-0 animate-spin" />
|
<Loader2Icon className="size-3.5 shrink-0 animate-spin" />
|
||||||
<span className="capitalize">{run.phase}</span>
|
<span className="capitalize">{t(run.phase)}</span>
|
||||||
<span className="max-w-48 truncate text-foreground">{run.title}</span>
|
<span className="max-w-48 truncate text-foreground">{run.title}</span>
|
||||||
<span className="tabular-nums">{Math.max(0, Math.round((Date.now() - run.startedAt) / 1000))}s</span>
|
<span className="tabular-nums">{t("{v0}s", { v0: Math.max(0, Math.round((Date.now() - run.startedAt) / 1000)) })}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -1641,7 +1654,7 @@ export function GraphView({
|
|||||||
<div className="mt-1 text-xs leading-relaxed text-muted-foreground">{selected.node.statement}</div>
|
<div className="mt-1 text-xs leading-relaxed text-muted-foreground">{selected.node.statement}</div>
|
||||||
{selected.sourceIds.length > 0 ? (
|
{selected.sourceIds.length > 0 ? (
|
||||||
<div className="mt-3 flex flex-col gap-1">
|
<div className="mt-3 flex flex-col gap-1">
|
||||||
<div className="text-xs text-muted-foreground">Sessions</div>
|
<div className="text-xs text-muted-foreground">{t("Sessions")}</div>
|
||||||
{selected.sourceIds.map((sourceId) => {
|
{selected.sourceIds.map((sourceId) => {
|
||||||
// An imported-notes file: a STATIC entry — the path,
|
// An imported-notes file: a STATIC entry — the path,
|
||||||
// plain text, never a link (no session to open).
|
// plain text, never a link (no session to open).
|
||||||
@@ -1670,7 +1683,7 @@ export function GraphView({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-3 text-xs text-muted-foreground">No sessions recorded.</div>
|
<div className="mt-3 text-xs text-muted-foreground">{t("No sessions recorded.")}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+7
-5
@@ -1,4 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { useI18n } from "@/components/i18n";
|
||||||
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
@@ -30,6 +32,7 @@ export function Home({
|
|||||||
* changing the setting applies on the next visit home. */
|
* changing the setting applies on the next visit home. */
|
||||||
rendering?: boolean;
|
rendering?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [topic, setTopic] = useState("");
|
const [topic, setTopic] = useState("");
|
||||||
/* The topic MIRROR ("nothing gets lost" round, 2026-10-01 — same
|
/* The topic MIRROR ("nothing gets lost" round, 2026-10-01 — same
|
||||||
@@ -236,8 +239,7 @@ export function Home({
|
|||||||
onClick={() => setSetupOpen(true)}
|
onClick={() => setSetupOpen(true)}
|
||||||
className="rounded-sm border border-border bg-card px-3 py-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50 hover:bg-accent cursor-pointer"
|
className="rounded-sm border border-border bg-card px-3 py-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50 hover:bg-accent cursor-pointer"
|
||||||
>
|
>
|
||||||
Set up your models
|
{t("Set up your models")}</button>
|
||||||
</button>
|
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -270,7 +272,7 @@ export function Home({
|
|||||||
void start();
|
void start();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder={mode === "practice" ? "What do you want to practice?" : "What do you want to learn?"}
|
placeholder={mode === "practice" ? t("What do you want to practice?") : t("What do you want to learn?")}
|
||||||
autoFocus
|
autoFocus
|
||||||
rows={1}
|
rows={1}
|
||||||
className="field-sizing-content lh-input max-h-48 min-h-10 flex-1 resize-none bg-transparent px-2.5 py-2 outline-none placeholder:text-muted-foreground"
|
className="field-sizing-content lh-input max-h-48 min-h-10 flex-1 resize-none bg-transparent px-2.5 py-2 outline-none placeholder:text-muted-foreground"
|
||||||
@@ -279,7 +281,7 @@ export function Home({
|
|||||||
affordance (owner, subtlety round 2026-09-25). */}
|
affordance (owner, subtlety round 2026-09-25). */}
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
aria-label="Start session"
|
aria-label={t("Start session")}
|
||||||
disabled={(!topic.trim() && !dictation.active) || busy}
|
disabled={(!topic.trim() && !dictation.active) || busy}
|
||||||
className="flex size-10 shrink-0 items-center justify-center rounded-[10px] bg-primary text-primary-foreground transition-colors hover:opacity-90 disabled:opacity-40"
|
className="flex size-10 shrink-0 items-center justify-center rounded-[10px] bg-primary text-primary-foreground transition-colors hover:opacity-90 disabled:opacity-40"
|
||||||
>
|
>
|
||||||
@@ -309,7 +311,7 @@ export function Home({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{m === "practice" ? <DumbbellIcon aria-hidden="true" className="size-3" /> : null}
|
{m === "practice" ? <DumbbellIcon aria-hidden="true" className="size-3" /> : null}
|
||||||
{m === "teach" ? "Teach" : "Practice"}
|
{m === "teach" ? t("Teach") : t("Practice")}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useCallback, useContext, useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
|
||||||
|
import { resolveLanguage, resolveSttLanguage, translator, type GeneralSettings, type Language, type SttLanguage, type Translator } from "@/src/core";
|
||||||
|
import { notifySettingsSaved, useSettingsSaved } from "./settings-signal";
|
||||||
|
import { MenuShell } from "./menu-shell";
|
||||||
|
import { CheckIcon } from "lucide-react";
|
||||||
|
|
||||||
|
type LanguageField = "language" | "sttLanguage";
|
||||||
|
type I18nValue = {
|
||||||
|
language: Language;
|
||||||
|
sttLanguage: SttLanguage;
|
||||||
|
general: GeneralSettings;
|
||||||
|
dictating: boolean;
|
||||||
|
setDictating: (active: boolean) => void;
|
||||||
|
t: Translator;
|
||||||
|
saveGeneral: (patch: Partial<GeneralSettings>) => Promise<void>;
|
||||||
|
saveLanguage: <Field extends LanguageField>(field: Field, value: GeneralSettings[Field]) => Promise<void>;
|
||||||
|
};
|
||||||
|
const I18nContext = createContext<I18nValue>({ language: "en", sttLanguage: "en", general: {}, dictating: false, setDictating: () => {}, t: translator("en"), saveGeneral: async () => {}, saveLanguage: async () => {} });
|
||||||
|
export function useI18n(): I18nValue { return useContext(I18nContext); }
|
||||||
|
|
||||||
|
/** Initial settings and locale come from the server, so hydration preserves the first frame. */
|
||||||
|
export function I18nProvider({ children, initialGeneral = {}, system = "en" }: { children: ReactNode; initialGeneral?: GeneralSettings; system?: Language }) {
|
||||||
|
const [general, setGeneral] = useState(initialGeneral);
|
||||||
|
const [dictating, setDictating] = useState(false);
|
||||||
|
const [detected, setDetected] = useState<Language>(system);
|
||||||
|
const pending = useRef<Promise<void>>(Promise.resolve());
|
||||||
|
const sequence = useRef(0);
|
||||||
|
const latest = useRef(general);
|
||||||
|
latest.current = general;
|
||||||
|
const language = resolveLanguage(general.language, detected);
|
||||||
|
const sttLanguage = resolveSttLanguage(general.sttLanguage, detected);
|
||||||
|
const t = useMemo(() => translator(language), [language]);
|
||||||
|
useEffect(() => {
|
||||||
|
const detect = () => setDetected(resolveLanguage(undefined, navigator.languages));
|
||||||
|
detect();
|
||||||
|
window.addEventListener("languagechange", detect);
|
||||||
|
return () => window.removeEventListener("languagechange", detect);
|
||||||
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.lang = language;
|
||||||
|
document.title = "Learning Harness";
|
||||||
|
const description = document.querySelector('meta[name="description"]');
|
||||||
|
if (description) description.setAttribute("content", t("A teacher agent with a memory — sessions, quizzes, and a knowledge graph of what you know."));
|
||||||
|
window.lh?.setLanguage?.(language);
|
||||||
|
}, [language, t]);
|
||||||
|
useSettingsSaved((section) => {
|
||||||
|
if (section !== "general") return;
|
||||||
|
const request = ++sequence.current;
|
||||||
|
void fetch("/api/settings").then((r) => { if (!r.ok) throw new Error("settings"); return r.json(); })
|
||||||
|
.then((body: { settings?: { general?: GeneralSettings } }) => { if (request === sequence.current) setGeneral(body.settings?.general ?? {}); }).catch(() => {});
|
||||||
|
});
|
||||||
|
// Serialize writes and re-read before replacing the section: simultaneous selectors cannot lose each other's changes.
|
||||||
|
const saveGeneral = useCallback((patch: Partial<GeneralSettings>) => {
|
||||||
|
const write = pending.current.catch(() => {}).then(async () => {
|
||||||
|
const current = await fetch("/api/settings");
|
||||||
|
if (!current.ok) throw new Error("Could not load language settings");
|
||||||
|
const body = await current.json() as { settings?: { general?: GeneralSettings } };
|
||||||
|
const next = { ...(body.settings?.general ?? latest.current) };
|
||||||
|
for (const field of ["language", "sttLanguage", "sttPreferredLanguages", "autoUpdate"] as const) {
|
||||||
|
if (!Object.hasOwn(patch, field)) continue;
|
||||||
|
if (patch[field] === undefined) delete next[field];
|
||||||
|
else Object.assign(next, { [field]: patch[field] });
|
||||||
|
}
|
||||||
|
const res = await fetch("/api/settings/general", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(next) });
|
||||||
|
if (!res.ok) throw new Error("Could not save language settings");
|
||||||
|
sequence.current++;
|
||||||
|
setGeneral(next);
|
||||||
|
notifySettingsSaved("general");
|
||||||
|
});
|
||||||
|
pending.current = write;
|
||||||
|
return write;
|
||||||
|
}, []);
|
||||||
|
const saveLanguage = useCallback(<Field extends LanguageField,>(field: Field, value: GeneralSettings[Field]) => saveGeneral({ [field]: value }), [saveGeneral]);
|
||||||
|
const value = useMemo(() => ({ language, sttLanguage, general, dictating, setDictating, t, saveGeneral, saveLanguage }), [language, sttLanguage, general, dictating, t, saveGeneral, saveLanguage]);
|
||||||
|
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LanguageSelect({ field, disabled = false, compact = false }: { field: "language" | "sttLanguage"; disabled?: boolean; compact?: boolean }) {
|
||||||
|
const { general, t, saveLanguage, sttLanguage, dictating } = useI18n();
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const trigger = useRef<HTMLButtonElement>(null);
|
||||||
|
const menuId = useId();
|
||||||
|
const locked = disabled || saving || (field === "sttLanguage" && dictating);
|
||||||
|
const menuOpen = open && !locked;
|
||||||
|
const compactStt = compact && field === "sttLanguage";
|
||||||
|
const saved = general[field] ?? "auto";
|
||||||
|
const selected = compactStt && saved === "auto" ? sttLanguage : saved;
|
||||||
|
const label = t(field === "language" ? "Interface language" : "Speech recognition language");
|
||||||
|
const testid = `language-select-${field}${compact ? "-compact" : ""}`;
|
||||||
|
const options = [
|
||||||
|
...(!compactStt ? [{ value: "auto", label: t("System default") } as const] : []),
|
||||||
|
{ value: "en", label: "English" },
|
||||||
|
{ value: "ru", label: "Русский" },
|
||||||
|
...(field === "sttLanguage" ? [{ value: "multi", label: t("Multilingual") } as const] : []),
|
||||||
|
] as const;
|
||||||
|
useEffect(() => { if (locked) setOpen(false); }, [locked]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!menuOpen) return;
|
||||||
|
const outside = (event: PointerEvent) => {
|
||||||
|
const target = event.target as Node;
|
||||||
|
if (!trigger.current?.contains(target) && !document.getElementById(menuId)?.contains(target)) setOpen(false);
|
||||||
|
};
|
||||||
|
const escape = (event: KeyboardEvent) => {
|
||||||
|
if (event.key !== "Escape") return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
setOpen(false);
|
||||||
|
trigger.current?.focus();
|
||||||
|
};
|
||||||
|
document.addEventListener("pointerdown", outside);
|
||||||
|
// Claim Escape before the settings dialog or dictation's outer layer.
|
||||||
|
document.addEventListener("keydown", escape, true);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("pointerdown", outside);
|
||||||
|
document.removeEventListener("keydown", escape, true);
|
||||||
|
};
|
||||||
|
}, [menuOpen, menuId]);
|
||||||
|
const pick = (value: "auto" | SttLanguage) => {
|
||||||
|
if (locked) return;
|
||||||
|
setOpen(false);
|
||||||
|
trigger.current?.focus();
|
||||||
|
if (value === saved) return;
|
||||||
|
setSaving(true); setError(null);
|
||||||
|
void saveLanguage(field, value === "auto" ? undefined : value).catch((e: unknown) => setError(e instanceof Error ? e.message : "Could not save language settings")).finally(() => setSaving(false));
|
||||||
|
};
|
||||||
|
return <span className={compact ? "flex shrink-0 items-center gap-1" : "flex flex-col items-end gap-1"}>
|
||||||
|
<button ref={trigger} type="button" aria-label={label} title={label} aria-haspopup="menu" aria-expanded={menuOpen} aria-controls={menuOpen ? menuId : undefined} disabled={locked} data-testid={testid}
|
||||||
|
className={`flex h-7 max-w-full items-center gap-1 rounded-sm border border-border bg-background px-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50 disabled:cursor-not-allowed disabled:opacity-50 ${compact ? "w-24" : "w-36"}`}
|
||||||
|
onClick={() => setOpen(!menuOpen)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
|
||||||
|
event.preventDefault(); event.stopPropagation(); setOpen(true);
|
||||||
|
}}>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-left">{compact && selected === "multi" ? t("Multi") : options.find((option) => option.value === selected)?.label}</span>
|
||||||
|
<span aria-hidden className="text-muted-foreground">▾</span>
|
||||||
|
</button>
|
||||||
|
{menuOpen && <MenuShell anchorRef={trigger} testid={`${testid}-menu`} preferredSide={compact ? "up" : "down"} menuClass="flex w-44 flex-col gap-0.5 rounded-xl border border-border bg-card p-2 text-xs shadow-xl">
|
||||||
|
<div id={menuId} role="menu" aria-label={label} className="flex flex-col gap-0.5">
|
||||||
|
{options.map((option) => <button key={option.value} type="button" role="menuitemradio" aria-checked={selected === option.value} onClick={() => pick(option.value)}
|
||||||
|
className={`flex items-center justify-between rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-muted ${selected === option.value ? "bg-muted/60 text-card-foreground" : "text-card-foreground"}`}>
|
||||||
|
<span>{option.label}</span>{selected === option.value && <span aria-hidden>✓</span>}
|
||||||
|
</button>)}
|
||||||
|
</div>
|
||||||
|
</MenuShell>}
|
||||||
|
{error && <span role="alert" className="max-w-56 text-xs text-destructive">{t(error)}</span>}
|
||||||
|
</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Preferred languages guide multilingual recognition; they are saved independently of its mode. */
|
||||||
|
export function SttPreferredLanguages() {
|
||||||
|
const { general, sttLanguage, dictating, saveGeneral, t } = useI18n();
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const preferred = general.sttPreferredLanguages ?? [];
|
||||||
|
const locked = sttLanguage !== "multi" || dictating || saving;
|
||||||
|
const toggle = (language: Language) => {
|
||||||
|
if (locked) return;
|
||||||
|
const next = (["en", "ru"] as const).filter((code) => code === language ? !preferred.includes(code) : preferred.includes(code));
|
||||||
|
setSaving(true); setError(null);
|
||||||
|
void saveGeneral({ sttPreferredLanguages: next })
|
||||||
|
.catch(() => setError("Could not save preferred languages"))
|
||||||
|
.finally(() => setSaving(false));
|
||||||
|
};
|
||||||
|
return <span className="flex max-w-full flex-col items-start gap-1">
|
||||||
|
<span role="group" aria-label={t("Preferred languages")} className="flex max-w-full flex-wrap gap-2">
|
||||||
|
{(["en", "ru"] as const).map((language) => <button key={language} type="button" role="checkbox" aria-checked={preferred.includes(language)}
|
||||||
|
data-testid={`stt-preferred-${language}`} disabled={locked} onClick={() => toggle(language)}
|
||||||
|
className={`flex h-7 items-center gap-1.5 rounded-sm border border-border px-1.5 font-ui text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${preferred.includes(language) ? "bg-muted text-foreground" : "bg-background text-muted-foreground hover:border-muted-foreground/50"}`}>
|
||||||
|
<span aria-hidden className="flex size-3.5 shrink-0 items-center justify-center rounded-xs border border-border">{preferred.includes(language) && <CheckIcon className="size-3" />}</span>
|
||||||
|
{language === "en" ? "English" : "Русский"}
|
||||||
|
</button>)}
|
||||||
|
</span>
|
||||||
|
{error && <span role="alert" className="max-w-56 text-xs text-destructive">{t(error)}</span>}
|
||||||
|
</span>;
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { useI18n } from "@/components/i18n";
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Keyboard shortcuts (shortcut wave A, 2026-09-17) — the single document-level
|
* Keyboard shortcuts (shortcut wave A, 2026-09-17) — the single document-level
|
||||||
@@ -151,13 +153,14 @@ export function KeyboardShortcuts() {
|
|||||||
* Until the settings milestone ships an editor, this is how shortcuts
|
* Until the settings milestone ships an editor, this is how shortcuts
|
||||||
* stay discoverable. */
|
* stay discoverable. */
|
||||||
function CheatSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
|
function CheatSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||||
|
const { t } = useI18n();
|
||||||
const backdropDismiss = useBackdropDismiss(onClose);
|
const backdropDismiss = useBackdropDismiss(onClose);
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
const scopes = ["global", "chat", "graph"] as const;
|
const scopes = ["global", "chat", "graph"] as const;
|
||||||
const scopeLabels: Record<(typeof scopes)[number], string> = {
|
const scopeLabels: Record<(typeof scopes)[number], string> = {
|
||||||
global: "Global",
|
global: t("Global"),
|
||||||
chat: "Chat",
|
chat: t("Chat"),
|
||||||
graph: "Graph view",
|
graph: t("Graph view"),
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -170,8 +173,8 @@ function CheatSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
|
|||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="mb-4 flex items-baseline justify-between">
|
<div className="mb-4 flex items-baseline justify-between">
|
||||||
<span className="text-sm font-medium text-card-foreground">Keyboard shortcuts</span>
|
<span className="text-sm font-medium text-card-foreground">{t("Keyboard shortcuts")}</span>
|
||||||
<span className="text-xs text-muted-foreground">Esc to close</span>
|
<span className="text-xs text-muted-foreground">{t("Esc to close")}</span>
|
||||||
</div>
|
</div>
|
||||||
{scopes.map((scope) => {
|
{scopes.map((scope) => {
|
||||||
const actions = KEYBOARD_ACTIONS.filter((a) => a.scope === scope);
|
const actions = KEYBOARD_ACTIONS.filter((a) => a.scope === scope);
|
||||||
@@ -184,9 +187,9 @@ function CheatSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
|
|||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{actions.map((a) => (
|
{actions.map((a) => (
|
||||||
<div key={a.id} className="flex items-center justify-between gap-4 text-sm">
|
<div key={a.id} className="flex items-center justify-between gap-4 text-sm">
|
||||||
<span className="text-muted-foreground">{a.label}</span>
|
<span className="text-muted-foreground">{t(a.label)}</span>
|
||||||
<kbd className="rounded-md border border-border bg-background px-1.5 py-0.5 font-mono text-xs text-card-foreground">
|
<kbd className="rounded-md border border-border bg-background px-1.5 py-0.5 font-mono text-xs text-card-foreground">
|
||||||
{formatBinding(bindingForAction(a.id))}
|
{t(formatBinding(bindingForAction(a.id)))}
|
||||||
</kbd>
|
</kbd>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { useI18n } from "@/components/i18n";
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MARKDOWN BLOCKS, MELTED INTO THE MESSAGE (owner wave, 2026-09-19): the
|
* MARKDOWN BLOCKS, MELTED INTO THE MESSAGE (owner wave, 2026-09-19): the
|
||||||
@@ -139,6 +141,7 @@ export function codeSource(children: ReactNode): string {
|
|||||||
* still streaming (the trailing open fence), a quiet skeleton instead of a
|
* still streaming (the trailing open fence), a quiet skeleton instead of a
|
||||||
* doomed parse. */
|
* doomed parse. */
|
||||||
function MermaidInline({ chart }: { chart: string }) {
|
function MermaidInline({ chart }: { chart: string }) {
|
||||||
|
const { t } = useI18n();
|
||||||
const [svg, setSvg] = useState<string | null>(null);
|
const [svg, setSvg] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [attempt, setAttempt] = useState(0);
|
const [attempt, setAttempt] = useState(0);
|
||||||
@@ -199,17 +202,17 @@ function MermaidInline({ chart }: { chart: string }) {
|
|||||||
dangerouslySetInnerHTML={{ __html: svg ?? "" }}
|
dangerouslySetInnerHTML={{ __html: svg ?? "" }}
|
||||||
/>
|
/>
|
||||||
<div className="absolute top-0 right-0 flex items-start gap-0.5">
|
<div className="absolute top-0 right-0 flex items-start gap-0.5">
|
||||||
<CopyCorner getText={() => chart} label="Copy diagram source" />
|
<CopyCorner getText={() => chart} label={t("Copy diagram source")} />
|
||||||
{overlay && (
|
{overlay && (
|
||||||
<Hint
|
<Hint
|
||||||
// Full-screen overlay copy (tooltip wave) — "session view" was
|
// Full-screen overlay copy (tooltip wave) — "session view" was
|
||||||
// internal direction-speak for the same thing.
|
// internal direction-speak for the same thing.
|
||||||
label="Open full-screen"
|
label={t("Open full-screen")}
|
||||||
side="bottom"
|
side="bottom"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Expand diagram"
|
aria-label={t("Expand diagram")}
|
||||||
data-testid="md-mermaid-expand"
|
data-testid="md-mermaid-expand"
|
||||||
className="flex size-6 items-center justify-center rounded-md text-muted-foreground/70 opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover/md:opacity-100"
|
className="flex size-6 items-center justify-center rounded-md text-muted-foreground/70 opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover/md:opacity-100"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -234,6 +237,7 @@ function MermaidInline({ chart }: { chart: string }) {
|
|||||||
* mounts. The container/look is CSS-melted in globals.css
|
* mounts. The container/look is CSS-melted in globals.css
|
||||||
* (data-streamdown="code-block"…). */
|
* (data-streamdown="code-block"…). */
|
||||||
export function MarkdownCode(props: ComponentProps<"code"> & ExtraProps) {
|
export function MarkdownCode(props: ComponentProps<"code"> & ExtraProps) {
|
||||||
|
const { t } = useI18n();
|
||||||
const incomplete = useIsCodeFenceIncomplete();
|
const incomplete = useIsCodeFenceIncomplete();
|
||||||
const { children, className, node, ...rest } = props;
|
const { children, className, node, ...rest } = props;
|
||||||
|
|
||||||
@@ -257,7 +261,7 @@ export function MarkdownCode(props: ComponentProps<"code"> & ExtraProps) {
|
|||||||
const lineNumbers = !/\bnoLineNumbers\b/.test(meta);
|
const lineNumbers = !/\bnoLineNumbers\b/.test(meta);
|
||||||
return (
|
return (
|
||||||
<div className="group/md relative" data-testid="md-code">
|
<div className="group/md relative" data-testid="md-code">
|
||||||
<CopyCorner className="absolute top-1.5 right-2 z-10" getText={() => source} label="Copy code" />
|
<CopyCorner className="absolute top-1.5 right-2 z-10" getText={() => source} label={t("Copy code")} />
|
||||||
<CodeBlock
|
<CodeBlock
|
||||||
code={source}
|
code={source}
|
||||||
language={language}
|
language={language}
|
||||||
@@ -374,6 +378,7 @@ export function MarkdownBlockquote(props: ComponentProps<"blockquote"> & ExtraPr
|
|||||||
* horizontal-scroller for wide ones, with the corner copy. The copy
|
* horizontal-scroller for wide ones, with the corner copy. The copy
|
||||||
* serializes the REAL DOM table (the library's own extractor → Markdown). */
|
* serializes the REAL DOM table (the library's own extractor → Markdown). */
|
||||||
export function MarkdownTable(props: ComponentProps<"table"> & ExtraProps) {
|
export function MarkdownTable(props: ComponentProps<"table"> & ExtraProps) {
|
||||||
|
const { t } = useI18n();
|
||||||
const { children, node, ...rest } = props;
|
const { children, node, ...rest } = props;
|
||||||
const ref = useRef<HTMLTableElement>(null);
|
const ref = useRef<HTMLTableElement>(null);
|
||||||
return (
|
return (
|
||||||
@@ -385,7 +390,7 @@ export function MarkdownTable(props: ComponentProps<"table"> & ExtraProps) {
|
|||||||
// cells it hovers over; the scroll container stays under it (the
|
// cells it hovers over; the scroll container stays under it (the
|
||||||
// corner is fixed to the wrapper, not the scrolling table).
|
// corner is fixed to the wrapper, not the scrolling table).
|
||||||
className="absolute top-1 right-1 z-10"
|
className="absolute top-1 right-1 z-10"
|
||||||
label="Copy table"
|
label={t("Copy table")}
|
||||||
getText={() => (ref.current ? tableDataToMarkdown(extractTableDataFromElement(ref.current)) : "")}
|
getText={() => (ref.current ? tableDataToMarkdown(extractTableDataFromElement(ref.current)) : "")}
|
||||||
/>
|
/>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
@@ -414,6 +419,7 @@ export function MarkdownTable(props: ComponentProps<"table"> & ExtraProps) {
|
|||||||
* in the MathML <annotation>; read from the DOM on click, no re-parse).
|
* in the MathML <annotation>; read from the DOM on click, no re-parse).
|
||||||
* Every other span passes through untouched. */
|
* Every other span passes through untouched. */
|
||||||
export function MathDisplay(props: ComponentProps<"span"> & ExtraProps) {
|
export function MathDisplay(props: ComponentProps<"span"> & ExtraProps) {
|
||||||
|
const { t } = useI18n();
|
||||||
const { children, className, node, ...rest } = props;
|
const { children, className, node, ...rest } = props;
|
||||||
const ref = useRef<HTMLSpanElement>(null);
|
const ref = useRef<HTMLSpanElement>(null);
|
||||||
if (!className?.includes("katex-display")) {
|
if (!className?.includes("katex-display")) {
|
||||||
@@ -434,7 +440,7 @@ export function MathDisplay(props: ComponentProps<"span"> & ExtraProps) {
|
|||||||
<span className="group/md relative block z-0" data-testid="md-math">
|
<span className="group/md relative block z-0" data-testid="md-math">
|
||||||
<CopyCorner
|
<CopyCorner
|
||||||
className="absolute top-0 right-0 z-10"
|
className="absolute top-0 right-0 z-10"
|
||||||
label="Copy equation source"
|
label={t("Copy equation source")}
|
||||||
getText={() => ref.current?.querySelector("annotation")?.textContent ?? ""}
|
getText={() => ref.current?.querySelector("annotation")?.textContent ?? ""}
|
||||||
/>
|
/>
|
||||||
<span ref={ref} className={className} {...rest}>
|
<span ref={ref} className={className} {...rest}>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { useI18n } from "@/components/i18n";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -15,7 +16,9 @@ export const MermaidError = ({
|
|||||||
error,
|
error,
|
||||||
retry,
|
retry,
|
||||||
className,
|
className,
|
||||||
}: MermaidErrorComponentProps & { className?: string }) => (
|
}: MermaidErrorComponentProps & { className?: string }) => {
|
||||||
|
const { t } = useI18n();
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"my-4 rounded-xl border border-destructive/40 bg-destructive/5 p-4",
|
"my-4 rounded-xl border border-destructive/40 bg-destructive/5 p-4",
|
||||||
@@ -24,21 +27,22 @@ export const MermaidError = ({
|
|||||||
data-streamdown="mermaid-error"
|
data-streamdown="mermaid-error"
|
||||||
>
|
>
|
||||||
<p className="text-sm font-medium text-destructive">
|
<p className="text-sm font-medium text-destructive">
|
||||||
Diagram failed to render
|
{t("Diagram failed to render")}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 font-mono text-xs break-words text-muted-foreground">
|
<p className="mt-1 font-mono text-xs break-words text-muted-foreground">
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
<details className="mt-3">
|
<details className="mt-3">
|
||||||
<summary className="cursor-pointer text-xs text-muted-foreground">
|
<summary className="cursor-pointer text-xs text-muted-foreground">
|
||||||
Show diagram source
|
{t("Show diagram source")}
|
||||||
</summary>
|
</summary>
|
||||||
<pre className="mt-2 overflow-x-auto rounded-lg bg-muted p-3 font-mono text-xs text-foreground">
|
<pre className="mt-2 overflow-x-auto rounded-lg bg-muted p-3 font-mono text-xs text-foreground">
|
||||||
{chart}
|
{chart}
|
||||||
</pre>
|
</pre>
|
||||||
</details>
|
</details>
|
||||||
<Button className="mt-3" onClick={retry} size="sm" variant="outline">
|
<Button className="mt-3" onClick={retry} size="sm" variant="outline">
|
||||||
Retry
|
{t("Retry")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|||||||
+15
-12
@@ -1,4 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { useI18n } from "@/components/i18n";
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* THE MINIMAL HOME (owner, 2026-10-06 — the no-graph home, take two):
|
* THE MINIMAL HOME (owner, 2026-10-06 — the no-graph home, take two):
|
||||||
@@ -24,6 +26,7 @@ import { ChainTableOverlay } from "@/components/chain-table.tsx";
|
|||||||
import { GraphCostControl } from "@/components/graph-view.tsx";
|
import { GraphCostControl } from "@/components/graph-view.tsx";
|
||||||
import { NotesImportErrors, useNotesImport } from "@/components/notes-import.tsx";
|
import { NotesImportErrors, useNotesImport } from "@/components/notes-import.tsx";
|
||||||
import { Hint } from "@/components/ui/hint";
|
import { Hint } from "@/components/ui/hint";
|
||||||
|
import { countLabel } from "@/src/core";
|
||||||
import type { ChatSummary, GraphCostSummary, GraphSnapshot } from "@/src/core";
|
import type { ChatSummary, GraphCostSummary, GraphSnapshot } from "@/src/core";
|
||||||
|
|
||||||
/** The quiet centered summary — a pure body (props in, markup out) so
|
/** The quiet centered summary — a pure body (props in, markup out) so
|
||||||
@@ -42,6 +45,7 @@ export function KnowledgeSummary({
|
|||||||
practiceSessions: number;
|
practiceSessions: number;
|
||||||
importedNotes: number;
|
importedNotes: number;
|
||||||
}) {
|
}) {
|
||||||
|
const { t, language } = useI18n();
|
||||||
const empty =
|
const empty =
|
||||||
propositions === 0 && chains === 0 && teachSessions === 0 && practiceSessions === 0 && importedNotes === 0;
|
propositions === 0 && chains === 0 && teachSessions === 0 && practiceSessions === 0 && importedNotes === 0;
|
||||||
return (
|
return (
|
||||||
@@ -52,29 +56,28 @@ export function KnowledgeSummary({
|
|||||||
list) rides after the same parting. */}
|
list) rides after the same parting. */}
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<p className="font-ui text-sm text-muted-foreground">
|
<p className="font-ui text-sm text-muted-foreground">
|
||||||
{propositions} {propositions === 1 ? "proposition" : "propositions"}
|
{countLabel(language, propositions, "proposition", "propositions")}
|
||||||
</p>
|
</p>
|
||||||
<p className="font-ui text-sm text-muted-foreground">
|
<p className="font-ui text-sm text-muted-foreground">
|
||||||
{chains} {chains === 1 ? "chain" : "chains"}
|
{countLabel(language, chains, "chain", "chains")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-4" aria-hidden="true" />
|
<div className="h-4" aria-hidden="true" />
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<p className="font-ui text-sm text-muted-foreground">
|
<p className="font-ui text-sm text-muted-foreground">
|
||||||
{teachSessions} {teachSessions === 1 ? "teaching session" : "teaching sessions"}
|
{countLabel(language, teachSessions, "teaching session", "teaching sessions")}
|
||||||
</p>
|
</p>
|
||||||
<p className="font-ui text-sm text-muted-foreground">
|
<p className="font-ui text-sm text-muted-foreground">
|
||||||
{practiceSessions} {practiceSessions === 1 ? "practice session" : "practice sessions"}
|
{countLabel(language, practiceSessions, "practice session", "practice sessions")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-4" aria-hidden="true" />
|
<div className="h-4" aria-hidden="true" />
|
||||||
<p className="font-ui text-sm text-muted-foreground">
|
<p className="font-ui text-sm text-muted-foreground">
|
||||||
{importedNotes} {importedNotes === 1 ? "imported note" : "imported notes"}
|
{countLabel(language, importedNotes, "imported note", "imported notes")}
|
||||||
</p>
|
</p>
|
||||||
{empty && (
|
{empty && (
|
||||||
<p className="pt-2 font-ui text-xs text-muted-foreground/70">
|
<p className="pt-2 font-ui text-xs text-muted-foreground/70">
|
||||||
Nothing here yet — import notes to begin building the graph.
|
{t("Nothing here yet — import notes to begin building the graph.")}</p>
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -93,6 +96,7 @@ export function MinimalHome({
|
|||||||
onPracticeSelectionChange?: (nodes: { id: string; name: string; statement: string }[]) => void;
|
onPracticeSelectionChange?: (nodes: { id: string; name: string; statement: string }[]) => void;
|
||||||
onChainSelectClose?: () => void;
|
onChainSelectClose?: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
const [snapshot, setSnapshot] = useState<GraphSnapshot | null>(null);
|
const [snapshot, setSnapshot] = useState<GraphSnapshot | null>(null);
|
||||||
const [costs, setCosts] = useState<GraphCostSummary | null>(null);
|
const [costs, setCosts] = useState<GraphCostSummary | null>(null);
|
||||||
const [chats, setChats] = useState<ChatSummary[]>([]);
|
const [chats, setChats] = useState<ChatSummary[]>([]);
|
||||||
@@ -167,7 +171,7 @@ export function MinimalHome({
|
|||||||
<div className="lh-no-drag lh-wco-floating absolute right-2 top-2 z-10 flex max-w-[calc(100%-5.5rem)] flex-col items-end gap-2">
|
<div className="lh-no-drag lh-wco-floating absolute right-2 top-2 z-10 flex max-w-[calc(100%-5.5rem)] flex-col items-end gap-2">
|
||||||
<div className="flex min-w-0 items-start gap-2">
|
<div className="flex min-w-0 items-start gap-2">
|
||||||
<div className="graph-chrome-fade-in shrink-0 rounded-xl border bg-card/85 px-1 py-[3px] shadow-lg backdrop-blur">
|
<div className="graph-chrome-fade-in shrink-0 rounded-xl border bg-card/85 px-1 py-[3px] shadow-lg backdrop-blur">
|
||||||
<Hint label="Every reasoning chain" side="bottom">
|
<Hint label={t("Every reasoning chain")} side="bottom">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setChainsOpen(true)}
|
onClick={() => setChainsOpen(true)}
|
||||||
@@ -175,12 +179,11 @@ export function MinimalHome({
|
|||||||
className="flex h-7 items-center gap-1.5 rounded-lg px-2.5 text-xs text-muted-foreground transition-colors hover:bg-accent"
|
className="flex h-7 items-center gap-1.5 rounded-lg px-2.5 text-xs text-muted-foreground transition-colors hover:bg-accent"
|
||||||
>
|
>
|
||||||
<ListTreeIcon className="size-3" />
|
<ListTreeIcon className="size-3" />
|
||||||
Chains
|
{t("Chains")}</button>
|
||||||
</button>
|
|
||||||
</Hint>
|
</Hint>
|
||||||
</div>
|
</div>
|
||||||
<div className="shrink-0 rounded-xl border bg-card/85 px-1 py-[3px] shadow-lg backdrop-blur">
|
<div className="shrink-0 rounded-xl border bg-card/85 px-1 py-[3px] shadow-lg backdrop-blur">
|
||||||
<Hint label="Import notes into the graph" side="bottom">
|
<Hint label={t("Import notes into the graph")} side="bottom">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={importing}
|
disabled={importing}
|
||||||
@@ -189,7 +192,7 @@ export function MinimalHome({
|
|||||||
className="flex h-7 items-center gap-1.5 rounded-lg px-2.5 text-xs text-muted-foreground transition-colors hover:bg-accent disabled:opacity-50"
|
className="flex h-7 items-center gap-1.5 rounded-lg px-2.5 text-xs text-muted-foreground transition-colors hover:bg-accent disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<UploadIcon className="size-3" />
|
<UploadIcon className="size-3" />
|
||||||
{importing ? "Importing…" : "Import notes…"}
|
{importing ? t("Importing…") : t("Import notes…")}
|
||||||
</button>
|
</button>
|
||||||
</Hint>
|
</Hint>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+46
-51
@@ -1,4 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { useI18n } from "@/components/i18n";
|
||||||
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import type { ChatSettings, ModelCatalogEntry, ModelCatalogResponse, ModelsSettings, UserSettings } from "@/src/core";
|
import type { ChatSettings, ModelCatalogEntry, ModelCatalogResponse, ModelsSettings, UserSettings } from "@/src/core";
|
||||||
@@ -8,7 +10,7 @@ import { ModelMenuList, ThinkingMenuList } from "@/components/settings/model-men
|
|||||||
import { composeSlotModel } from "@/components/settings/agent-section";
|
import { composeSlotModel } from "@/components/settings/agent-section";
|
||||||
import { MenuShell } from "@/components/menu-shell";
|
import { MenuShell } from "@/components/menu-shell";
|
||||||
import { Hint } from "@/components/ui/hint";
|
import { Hint } from "@/components/ui/hint";
|
||||||
import { notifySettingsSaved, useSettingsSaved } from "@/components/settings-signal";
|
import { notifySettingsSaved, onSettingsSaved, useSettingsSaved } from "@/components/settings-signal";
|
||||||
import { defaultOrderLevel, populateLevel, THINKING_LABEL } from "@/components/thinking-default";
|
import { defaultOrderLevel, populateLevel, THINKING_LABEL } from "@/components/thinking-default";
|
||||||
|
|
||||||
/** GET /api/models is cached per page load for instant menu opens, but
|
/** GET /api/models is cached per page load for instant menu opens, but
|
||||||
@@ -17,51 +19,40 @@ import { defaultOrderLevel, populateLevel, THINKING_LABEL } from "@/components/t
|
|||||||
* every model-menu open (stale-while-revalidate: the cached list shows
|
* every model-menu open (stale-while-revalidate: the cached list shows
|
||||||
* instantly, the fresh one swaps in while the menu is open). */
|
* instantly, the fresh one swaps in while the menu is open). */
|
||||||
let catalogCache: ModelCatalogResponse | undefined;
|
let catalogCache: ModelCatalogResponse | undefined;
|
||||||
let catalogInflight: Promise<ModelCatalogResponse> | undefined;
|
let catalogInflight: Promise<void> | undefined;
|
||||||
|
let catalogGeneration = 0;
|
||||||
function fetchCatalog(force = false): Promise<ModelCatalogResponse> {
|
let stopCatalogSignals: (() => void) | undefined;
|
||||||
if (force) {
|
const catalogReaders = new Set<() => void>();
|
||||||
catalogCache = undefined;
|
const publishCatalog = () => { for (const reader of catalogReaders) reader(); };
|
||||||
catalogInflight = undefined;
|
function fetchCatalog(revalidate = false): void {
|
||||||
}
|
if (revalidate) { catalogGeneration++; catalogInflight = undefined; }
|
||||||
catalogInflight ??= fetch("/api/models")
|
if (catalogInflight) return;
|
||||||
.then((r) => {
|
const generation = catalogGeneration;
|
||||||
if (!r.ok) throw new Error(`catalog request failed: ${r.status}`);
|
catalogInflight = fetch("/api/models")
|
||||||
return r.json() as Promise<ModelCatalogResponse>;
|
.then((r) => { if (!r.ok) throw new Error(`catalog request failed: ${r.status}`); return r.json() as Promise<ModelCatalogResponse>; })
|
||||||
})
|
.then((c) => { if (generation === catalogGeneration) { catalogCache = c; publishCatalog(); } })
|
||||||
.then((c) => {
|
.catch(() => { /* existing unavailable/usage-only posture, no retry timer */ })
|
||||||
catalogCache = c;
|
.finally(() => { if (generation === catalogGeneration) catalogInflight = undefined; });
|
||||||
return c;
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
catalogInflight = undefined; // retry on next mount/open
|
|
||||||
throw err;
|
|
||||||
});
|
|
||||||
return catalogInflight;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useModelCatalog(): { catalog: ModelCatalogResponse | undefined; refresh: () => void } {
|
export function useModelCatalog(): { catalog: ModelCatalogResponse | undefined; refresh: () => void } {
|
||||||
const [catalog, setCatalog] = useState(catalogCache);
|
const [catalog, setCatalog] = useState(catalogCache);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (catalogCache) return;
|
const first = catalogReaders.size === 0;
|
||||||
let alive = true;
|
const read = () => setCatalog(catalogCache);
|
||||||
fetchCatalog()
|
catalogReaders.add(read);
|
||||||
.then((c) => alive && setCatalog(c))
|
if (first) {
|
||||||
.catch(() => {
|
stopCatalogSignals = onSettingsSaved((section) => {
|
||||||
/* the picker stays closed on failure; the buttons still
|
if (section === "keys") {
|
||||||
display the strong slot from the record fetch */
|
catalogGeneration++; catalogInflight = undefined; catalogCache = undefined;
|
||||||
});
|
publishCatalog(); fetchCatalog();
|
||||||
return () => {
|
} else if (section === "models") fetchCatalog(true);
|
||||||
alive = false;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
const refresh = useCallback(() => {
|
|
||||||
fetchCatalog(true)
|
|
||||||
.then(setCatalog)
|
|
||||||
.catch(() => {
|
|
||||||
/* refresh is best-effort: the cached list stays on failure */
|
|
||||||
});
|
});
|
||||||
|
fetchCatalog(true);
|
||||||
|
} else fetchCatalog();
|
||||||
|
read();
|
||||||
|
return () => { catalogReaders.delete(read); if (!catalogReaders.size) { stopCatalogSignals?.(); stopCatalogSignals = undefined; } };
|
||||||
}, []);
|
}, []);
|
||||||
|
const refresh = useCallback(() => fetchCatalog(true), []);
|
||||||
return { catalog, refresh };
|
return { catalog, refresh };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +83,8 @@ export function catalogLimits(catalog: Pick<ModelCatalogResponse, "models">): Ma
|
|||||||
* changing the new-session default. Unpicked reads "—" (the
|
* changing the new-session default. Unpicked reads "—" (the
|
||||||
* no-factory-fallback language: the UI never presents a default model).
|
* no-factory-fallback language: the UI never presents a default model).
|
||||||
*/
|
*/
|
||||||
export function ModelControl({ chatId }: { chatId?: string }) {
|
export function ModelControl({ chatId, onModelChange }: { chatId?: string; onModelChange?: (model: string | undefined) => void }) {
|
||||||
|
const { t } = useI18n();
|
||||||
const { catalog, refresh } = useModelCatalog();
|
const { catalog, refresh } = useModelCatalog();
|
||||||
const [models, setModels] = useState<ModelsSettings | undefined>();
|
const [models, setModels] = useState<ModelsSettings | undefined>();
|
||||||
const [session, setSession] = useState<ChatSettings | undefined>();
|
const [session, setSession] = useState<ChatSettings | undefined>();
|
||||||
@@ -147,6 +139,9 @@ export function ModelControl({ chatId }: { chatId?: string }) {
|
|||||||
: strong;
|
: strong;
|
||||||
const effectiveId = override?.model?.id;
|
const effectiveId = override?.model?.id;
|
||||||
const effectiveProvider = override?.model?.provider;
|
const effectiveProvider = override?.model?.provider;
|
||||||
|
useEffect(() => {
|
||||||
|
onModelChange?.(effectiveProvider && effectiveId ? `${effectiveProvider}:${effectiveId}` : undefined);
|
||||||
|
}, [effectiveProvider, effectiveId, onModelChange]);
|
||||||
const storedLevel = override?.thinkingLevel;
|
const storedLevel = override?.thinkingLevel;
|
||||||
const entry = catalog?.models.find(
|
const entry = catalog?.models.find(
|
||||||
(m) => m.id === effectiveId && m.provider === effectiveProvider,
|
(m) => m.id === effectiveId && m.provider === effectiveProvider,
|
||||||
@@ -326,7 +321,7 @@ export function ModelControl({ chatId }: { chatId?: string }) {
|
|||||||
// Trimmed (owner, subtlety round 2026-09-25): "Model selector"
|
// Trimmed (owner, subtlety round 2026-09-25): "Model selector"
|
||||||
// — the long sentence was way too much. ⌘K opens this same
|
// — the long sentence was way too much. ⌘K opens this same
|
||||||
// menu (registry, override-aware).
|
// menu (registry, override-aware).
|
||||||
label="Model selector"
|
label={t("Model selector")}
|
||||||
action="open-model-picker"
|
action="open-model-picker"
|
||||||
side="bottom"
|
side="bottom"
|
||||||
>
|
>
|
||||||
@@ -349,7 +344,7 @@ export function ModelControl({ chatId }: { chatId?: string }) {
|
|||||||
// "Thinking" + its real shortcut via the registry (owner interlude
|
// "Thinking" + its real shortcut via the registry (owner interlude
|
||||||
// 2026-09-25): ⇧⇥ cycles the level, so "Thinking (⇧⇥)" —
|
// 2026-09-25): ⇧⇥ cycles the level, so "Thinking (⇧⇥)" —
|
||||||
// rebind-live like every Hint suffix.
|
// rebind-live like every Hint suffix.
|
||||||
label="Thinking"
|
label={t("Thinking")}
|
||||||
action="cycle-thinking"
|
action="cycle-thinking"
|
||||||
side="bottom"
|
side="bottom"
|
||||||
>
|
>
|
||||||
@@ -372,7 +367,7 @@ export function ModelControl({ chatId }: { chatId?: string }) {
|
|||||||
{/* Just the level — the button reads like the model button reads
|
{/* Just the level — the button reads like the model button reads
|
||||||
the model name (owner, stats-control round 1). */}
|
the model name (owner, stats-control round 1). */}
|
||||||
<span className="text-card-foreground">
|
<span className="text-card-foreground">
|
||||||
{effectiveLevel === undefined ? "—" : (THINKING_LABEL[effectiveLevel] ?? effectiveLevel)}
|
{effectiveLevel === undefined ? "—" : t(THINKING_LABEL[effectiveLevel] ?? effectiveLevel)}
|
||||||
</span>
|
</span>
|
||||||
<span aria-hidden className="text-xs text-muted-foreground">▾</span>
|
<span aria-hidden className="text-xs text-muted-foreground">▾</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -388,7 +383,7 @@ export function ModelControl({ chatId }: { chatId?: string }) {
|
|||||||
preferredSide="up"
|
preferredSide="up"
|
||||||
menuClass="flex w-[22rem] flex-col rounded-xl border bg-card p-2 text-xs shadow-xl"
|
menuClass="flex w-[22rem] flex-col rounded-xl border bg-card p-2 text-xs shadow-xl"
|
||||||
>
|
>
|
||||||
{error && <div className="mb-1.5 text-destructive">{error}</div>}
|
{error && <div className="mb-1.5 text-destructive">{t(error)}</div>}
|
||||||
<ModelMenuList
|
<ModelMenuList
|
||||||
models={catalog?.models}
|
models={catalog?.models}
|
||||||
effectiveId={effectiveId}
|
effectiveId={effectiveId}
|
||||||
@@ -396,10 +391,10 @@ export function ModelControl({ chatId }: { chatId?: string }) {
|
|||||||
onPick={pickModel}
|
onPick={pickModel}
|
||||||
footer={
|
footer={
|
||||||
catalog && catalog.models.length === 0
|
catalog && catalog.models.length === 0
|
||||||
? "No models — add a provider key in Keys"
|
? t("No models — add a provider key in Keys")
|
||||||
: chatId
|
: chatId
|
||||||
? "This session's model"
|
? t("This session's model")
|
||||||
: "Strong tier — the default for new sessions"
|
: t("Strong tier — the default for new sessions")
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</MenuShell>
|
</MenuShell>
|
||||||
@@ -416,10 +411,10 @@ export function ModelControl({ chatId }: { chatId?: string }) {
|
|||||||
levels={catalog ? levels : []}
|
levels={catalog ? levels : []}
|
||||||
effectiveLevel={effectiveLevel}
|
effectiveLevel={effectiveLevel}
|
||||||
onPick={(level) => apply(effectiveProvider ?? "", effectiveId ?? "", level as ThinkingLevel)}
|
onPick={(level) => apply(effectiveProvider ?? "", effectiveId ?? "", level as ThinkingLevel)}
|
||||||
empty={catalog ? (effectiveId ? `${prettyModel(effectiveId)} has no thinking levels.` : "Pick a model first.") : "Loading…"}
|
empty={catalog ? (effectiveId ? t("{v0} has no thinking levels.", { v0: prettyModel(effectiveId) }) : t("Pick a model first.")) : t("Loading…")}
|
||||||
footer="Levels this model actually supports"
|
footer={t("Levels this model actually supports")}
|
||||||
/>
|
/>
|
||||||
{error && <div className="mt-1 text-destructive">{error}</div>}
|
{error && <div className="mt-1 text-destructive">{t(error)}</div>}
|
||||||
</MenuShell>
|
</MenuShell>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { useI18n } from "@/components/i18n";
|
||||||
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
@@ -110,6 +112,7 @@ export function DraftShell({
|
|||||||
* Passed as a node so the pick state stays in NewSession. */
|
* Passed as a node so the pick state stays in NewSession. */
|
||||||
overlay?: ReactNode;
|
overlay?: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
// Voice dictation (STT wave, 2026-09-17): finals append at the end of the
|
// Voice dictation (STT wave, 2026-09-17): finals append at the end of the
|
||||||
// draft text (via the parent's onChange — the truth lives there); the
|
// draft text (via the parent's onChange — the truth lives there); the
|
||||||
// strip lives in the stats bar's middle slot.
|
// strip lives in the stats bar's middle slot.
|
||||||
@@ -172,11 +175,11 @@ export function DraftShell({
|
|||||||
<Conversation className="h-full min-w-0 flex-1" initial="instant">
|
<Conversation className="h-full min-w-0 flex-1" initial="instant">
|
||||||
<ConversationContent className="min-h-full gap-0 p-0">
|
<ConversationContent className="min-h-full gap-0 p-0">
|
||||||
<ConversationEmptyState
|
<ConversationEmptyState
|
||||||
title={mode === "practice" ? "New practice session" : "New session"}
|
title={mode === "practice" ? t("New practice session") : t("New session")}
|
||||||
description={
|
description={
|
||||||
mode === "practice"
|
mode === "practice"
|
||||||
? "Pick nodes from your knowledge graph, describe what you want to practice, or bring your own problem."
|
? t("Pick nodes from your knowledge graph, describe what you want to practice, or bring your own problem.")
|
||||||
: "The teacher will open by probing what you already know."
|
: t("The teacher will open by probing what you already know.")
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</ConversationContent>
|
</ConversationContent>
|
||||||
@@ -205,7 +208,7 @@ export function DraftShell({
|
|||||||
// input (owner refinement round, 2026-09-24) — no lesson has
|
// input (owner refinement round, 2026-09-24) — no lesson has
|
||||||
// started yet, so the composer must not read like one is in
|
// started yet, so the composer must not read like one is in
|
||||||
// flight.
|
// flight.
|
||||||
placeholder={mode === "practice" ? "What do you want to practice?" : "What do you want to learn?"}
|
placeholder={mode === "practice" ? t("What do you want to practice?") : t("What do you want to learn?")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<StatsBar
|
<StatsBar
|
||||||
@@ -231,6 +234,7 @@ export function DraftShell({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function NewSession({ initialConfigured, mode = "teach" }: { initialConfigured: boolean; mode?: "teach" | "practice" }) {
|
export function NewSession({ initialConfigured, mode = "teach" }: { initialConfigured: boolean; mode?: "teach" | "practice" }) {
|
||||||
|
const { t } = useI18n();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [text, setText] = useState("");
|
const [text, setText] = useState("");
|
||||||
/* The draft MIRROR ("nothing gets lost" round, 2026-10-01): the shell's
|
/* The draft MIRROR ("nothing gets lost" round, 2026-10-01): the shell's
|
||||||
@@ -357,8 +361,7 @@ export function NewSession({ initialConfigured, mode = "teach" }: { initialConfi
|
|||||||
onClick={() => setSetupOpen(true)}
|
onClick={() => setSetupOpen(true)}
|
||||||
className="rounded-sm border border-border bg-card px-3 py-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50 hover:bg-accent cursor-pointer"
|
className="rounded-sm border border-border bg-card px-3 py-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50 hover:bg-accent cursor-pointer"
|
||||||
>
|
>
|
||||||
Set up your models
|
{t("Set up your models")}</button>
|
||||||
</button>
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { useI18n } from "@/components/i18n";
|
||||||
|
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
|
|
||||||
@@ -53,24 +54,26 @@ export async function postNotesImport(files: readonly File[]): Promise<NotesImpo
|
|||||||
|
|
||||||
/** Only error text survives a request; selected Files stay request-local. */
|
/** Only error text survives a request; selected Files stay request-local. */
|
||||||
export function useNotesImport(callbacks: { onStart?: () => void; onFinish?: () => void } = {}) {
|
export function useNotesImport(callbacks: { onStart?: () => void; onFinish?: () => void } = {}) {
|
||||||
const [errors, setErrors] = useState<string[]>([]);
|
const { t } = useI18n();
|
||||||
|
const [failures, setFailures] = useState<Array<{ file: string; message: string; detail?: string }>>([]);
|
||||||
|
const errors = failures.map((failure) => `${failure.file}: ${t(failure.message)}${failure.detail ?? ""}`);
|
||||||
const [importing, setImporting] = useState(false);
|
const [importing, setImporting] = useState(false);
|
||||||
const pending = useRef(false);
|
const pending = useRef(false);
|
||||||
const submit = async (files: File[]) => {
|
const submit = async (files: File[]) => {
|
||||||
if (pending.current || files.length === 0) return;
|
if (pending.current || files.length === 0) return;
|
||||||
pending.current = true;
|
pending.current = true;
|
||||||
setImporting(true);
|
setImporting(true);
|
||||||
setErrors([]);
|
setFailures([]);
|
||||||
try {
|
try {
|
||||||
callbacks.onStart?.();
|
callbacks.onStart?.();
|
||||||
const results = await postNotesImport(files);
|
const results = await postNotesImport(files);
|
||||||
setErrors(results
|
setFailures(results
|
||||||
.filter((result) => !result.ok)
|
.filter((result) => !result.ok)
|
||||||
.sort((a, b) => a.requestIndex - b.requestIndex)
|
.sort((a, b) => a.requestIndex - b.requestIndex)
|
||||||
.map((r) => `${r.filePath}: ${r.error}${r.code ? `\n${r.code}${r.provider ? ` — ${r.provider}` : ""}${r.missing?.length ? ` — ${r.missing.join(", ")}` : ""}${r.slots?.length ? ` — ${r.slots.join(", ")}` : ""}` : ""}`));
|
.map((r) => ({ file: r.filePath, message: r.error ?? "", detail: r.code ? `\n${r.code}${r.provider ? ` — ${r.provider}` : ""}${r.missing?.length ? ` — ${r.missing.join(", ")}` : ""}${r.slots?.length ? ` — ${r.slots.join(", ")}` : ""}` : "" })));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
setErrors(files.map((file) => `${file.name}: ${message}`));
|
setFailures(files.map((file) => ({ file: file.name, message })));
|
||||||
} finally {
|
} finally {
|
||||||
pending.current = false;
|
pending.current = false;
|
||||||
setImporting(false);
|
setImporting(false);
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useI18n } from "@/components/i18n";
|
||||||
import type { Phase3 } from "@/src/core";
|
import type { Phase3 } from "@/src/core";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -39,6 +42,7 @@ const CHIP_LABEL: Record<Phase3, string> = {
|
|||||||
* at all, so it is part of the drag surface instead of a carved-out
|
* at all, so it is part of the drag surface instead of a carved-out
|
||||||
* target. */
|
* target. */
|
||||||
export function PhaseChip({ phase }: { phase: Phase3 }) {
|
export function PhaseChip({ phase }: { phase: Phase3 }) {
|
||||||
|
const { t } = useI18n();
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
data-testid="phase-chip"
|
data-testid="phase-chip"
|
||||||
@@ -46,7 +50,7 @@ export function PhaseChip({ phase }: { phase: Phase3 }) {
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="pointer-events-none flex shrink-0 select-none items-center text-xs font-medium uppercase tracking-wide text-muted-foreground"
|
className="pointer-events-none flex shrink-0 select-none items-center text-xs font-medium uppercase tracking-wide text-muted-foreground"
|
||||||
>
|
>
|
||||||
{CHIP_LABEL[phase]}
|
{t(CHIP_LABEL[phase])}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -60,11 +64,12 @@ export function PhaseChip({ phase }: { phase: Phase3 }) {
|
|||||||
* after the start).
|
* after the start).
|
||||||
*/
|
*/
|
||||||
export function PhaseDivider({ phase, nodeCount }: { phase: Phase3; nodeCount?: number }) {
|
export function PhaseDivider({ phase, nodeCount }: { phase: Phase3; nodeCount?: number }) {
|
||||||
|
const { t } = useI18n();
|
||||||
if (phase === "probe") return null;
|
if (phase === "probe") return null;
|
||||||
const label =
|
const label =
|
||||||
phase === "plan" && nodeCount
|
phase === "plan" && nodeCount
|
||||||
? `Plan · ${nodeCount} node${nodeCount === 1 ? "" : "s"}`
|
? t("Plan · {v0} node{v1}", { v0: nodeCount, v1: nodeCount === 1 ? "" : "s" })
|
||||||
: CHIP_LABEL[phase];
|
: t(CHIP_LABEL[phase]);
|
||||||
return (
|
return (
|
||||||
<div className="my-4 flex w-full items-center gap-3" data-testid={`phase-divider-${phase}`}>
|
<div className="my-4 flex w-full items-center gap-3" data-testid={`phase-divider-${phase}`}>
|
||||||
<span className="h-0 flex-1 border-t-2 border-dotted border-border/50" aria-hidden />
|
<span className="h-0 flex-1 border-t-2 border-dotted border-border/50" aria-hidden />
|
||||||
|
|||||||
+21
-17
@@ -1,4 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { useI18n } from "@/components/i18n";
|
||||||
|
|
||||||
|
|
||||||
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||||
import { MaximizeIcon, MinusIcon, PlusIcon, XIcon } from "lucide-react";
|
import { MaximizeIcon, MinusIcon, PlusIcon, XIcon } from "lucide-react";
|
||||||
@@ -509,6 +511,7 @@ export function PlanDiagram({
|
|||||||
* flank's width and the band's height rather than run width-first. */
|
* flank's width and the band's height rather than run width-first. */
|
||||||
fill?: boolean;
|
fill?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
const layout = useMemo(() => blockLayout(nodes, edges), [nodes, edges]);
|
const layout = useMemo(() => blockLayout(nodes, edges), [nodes, edges]);
|
||||||
// Pad only the full-extent render — the plan window's override viewBox is
|
// Pad only the full-extent render — the plan window's override viewBox is
|
||||||
// a deliberate viewport crop, and the popup keeps its own fit margins.
|
// a deliberate viewport crop, and the popup keeps its own fit margins.
|
||||||
@@ -540,7 +543,7 @@ export function PlanDiagram({
|
|||||||
: {}),
|
: {}),
|
||||||
}}
|
}}
|
||||||
role="img"
|
role="img"
|
||||||
aria-label="Plan diagram"
|
aria-label={t("Plan diagram")}
|
||||||
>
|
>
|
||||||
<defs>
|
<defs>
|
||||||
<marker
|
<marker
|
||||||
@@ -676,15 +679,16 @@ export function PlanDiagram({
|
|||||||
* future DAG window).
|
* future DAG window).
|
||||||
*/
|
*/
|
||||||
export function PlanBlock({ data, onOpen }: { data: PlanBlockData; onOpen: () => void }) {
|
export function PlanBlock({ data, onOpen }: { data: PlanBlockData; onOpen: () => void }) {
|
||||||
|
const { t } = useI18n();
|
||||||
const markerId = useId().replace(/[^a-zA-Z0-9-]/g, "");
|
const markerId = useId().replace(/[^a-zA-Z0-9-]/g, "");
|
||||||
const statusMap = useMemo(() => new Map(data.nodes.map((n) => [n.id, n.status] as const)), [data]);
|
const statusMap = useMemo(() => new Map(data.nodes.map((n) => [n.id, n.status] as const)), [data]);
|
||||||
const statusOf = (id: string): PlanNodeStatus => statusMap.get(id) ?? "upcoming";
|
const statusOf = (id: string): PlanNodeStatus => statusMap.get(id) ?? "upcoming";
|
||||||
return (
|
return (
|
||||||
<Hint label="Open the full plan" side="bottom">
|
<Hint label={t("Open the full plan")} side="bottom">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="plan-block-full"
|
data-testid="plan-block-full"
|
||||||
aria-label="Open the full plan"
|
aria-label={t("Open the full plan")}
|
||||||
onClick={onOpen}
|
onClick={onOpen}
|
||||||
className="block w-full text-left"
|
className="block w-full text-left"
|
||||||
>
|
>
|
||||||
@@ -749,6 +753,7 @@ const POPUP_PAD = GLOW_REACH;
|
|||||||
* through while the focus owns the session area.
|
* through while the focus owns the session area.
|
||||||
*/
|
*/
|
||||||
export function PlanPopup({ messages, onClose }: { messages: readonly PopupMessage[]; onClose: () => void }) {
|
export function PlanPopup({ messages, onClose }: { messages: readonly PopupMessage[]; onClose: () => void }) {
|
||||||
|
const { t } = useI18n();
|
||||||
const tree = useMemo(() => latestTree(messages), [messages]);
|
const tree = useMemo(() => latestTree(messages), [messages]);
|
||||||
const statuses = useMemo(
|
const statuses = useMemo(
|
||||||
() => (tree ? derivePlanStatuses(tree, messages as Parameters<typeof derivePlanStatuses>[1]) : null),
|
() => (tree ? derivePlanStatuses(tree, messages as Parameters<typeof derivePlanStatuses>[1]) : null),
|
||||||
@@ -887,19 +892,19 @@ export function PlanPopup({ messages, onClose }: { messages: readonly PopupMessa
|
|||||||
className="absolute inset-0 z-40 flex flex-col bg-background/80 p-4 backdrop-blur-sm"
|
className="absolute inset-0 z-40 flex flex-col bg-background/80 p-4 backdrop-blur-sm"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label="The full plan"
|
aria-label={t("The full plan")}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (e.target === e.currentTarget) onClose();
|
if (e.target === e.currentTarget) onClose();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="mb-2 flex items-center gap-2">
|
<div className="mb-2 flex items-center gap-2">
|
||||||
<span className="text-sm font-medium">The plan</span>
|
<span className="text-sm font-medium">{t("The plan")}</span>
|
||||||
<span className="text-xs text-muted-foreground">live — statuses derive from your check outcomes</span>
|
<span className="text-xs text-muted-foreground">{t("live — statuses derive from your check outcomes")}</span>
|
||||||
<Hint label="Close" binding="escape" side="left">
|
<Hint label={t("Close")} binding="escape" side="left">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
aria-label="Close"
|
aria-label={t("Close")}
|
||||||
data-testid="plan-popup-close"
|
data-testid="plan-popup-close"
|
||||||
className="ml-auto rounded-lg p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
className="ml-auto rounded-lg p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||||
>
|
>
|
||||||
@@ -947,22 +952,22 @@ export function PlanPopup({ messages, onClose }: { messages: readonly PopupMessa
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-between p-2">
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-between p-2">
|
||||||
<span className="text-xs text-muted-foreground/60">Drag to pan · scroll to zoom</span>
|
<span className="text-xs text-muted-foreground/60">{t("Drag to pan · scroll to zoom")}</span>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<Hint label="Zoom out" side="bottom">
|
<Hint label={t("Zoom out")} side="bottom">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Zoom out"
|
aria-label={t("Zoom out")}
|
||||||
onClick={() => stepZoom(1 / 1.25)}
|
onClick={() => stepZoom(1 / 1.25)}
|
||||||
className="pointer-events-auto flex size-7 items-center justify-center rounded-md border border-border/70 bg-card/80 text-muted-foreground backdrop-blur transition-colors hover:bg-accent hover:text-foreground"
|
className="pointer-events-auto flex size-7 items-center justify-center rounded-md border border-border/70 bg-card/80 text-muted-foreground backdrop-blur transition-colors hover:bg-accent hover:text-foreground"
|
||||||
>
|
>
|
||||||
<MinusIcon className="size-3.5" />
|
<MinusIcon className="size-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</Hint>
|
</Hint>
|
||||||
<Hint label="Fit plan to screen" side="bottom">
|
<Hint label={t("Fit plan to screen")} side="bottom">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Fit plan to screen"
|
aria-label={t("Fit plan to screen")}
|
||||||
onClick={fitView}
|
onClick={fitView}
|
||||||
data-testid="plan-popup-fit"
|
data-testid="plan-popup-fit"
|
||||||
className="pointer-events-auto flex size-7 items-center justify-center rounded-md border border-border/70 bg-card/80 text-muted-foreground backdrop-blur transition-colors hover:bg-accent hover:text-foreground"
|
className="pointer-events-auto flex size-7 items-center justify-center rounded-md border border-border/70 bg-card/80 text-muted-foreground backdrop-blur transition-colors hover:bg-accent hover:text-foreground"
|
||||||
@@ -970,10 +975,10 @@ export function PlanPopup({ messages, onClose }: { messages: readonly PopupMessa
|
|||||||
<MaximizeIcon className="size-3.5" />
|
<MaximizeIcon className="size-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</Hint>
|
</Hint>
|
||||||
<Hint label="Zoom in" side="bottom">
|
<Hint label={t("Zoom in")} side="bottom">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Zoom in"
|
aria-label={t("Zoom in")}
|
||||||
onClick={() => stepZoom(1.25)}
|
onClick={() => stepZoom(1.25)}
|
||||||
className="pointer-events-auto flex size-7 items-center justify-center rounded-md border border-border/70 bg-card/80 text-muted-foreground backdrop-blur transition-colors hover:bg-accent hover:text-foreground"
|
className="pointer-events-auto flex size-7 items-center justify-center rounded-md border border-border/70 bg-card/80 text-muted-foreground backdrop-blur transition-colors hover:bg-accent hover:text-foreground"
|
||||||
>
|
>
|
||||||
@@ -985,8 +990,7 @@ export function PlanPopup({ messages, onClose }: { messages: readonly PopupMessa
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||||
No plan in this session yet.
|
{t("No plan in this session yet.")}</div>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { useI18n } from "@/components/i18n";
|
||||||
|
|
||||||
|
|
||||||
import { MessageResponse } from "@/components/ai-elements/message";
|
import { MessageResponse } from "@/components/ai-elements/message";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -56,6 +58,7 @@ export interface PracticeThinkingData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ProblemCard({ part }: ArtifactPartProps) {
|
export function ProblemCard({ part }: ArtifactPartProps) {
|
||||||
|
const { t } = useI18n();
|
||||||
// Always open, no chevron (owner round, 2026-10-05): the record IS the
|
// Always open, no chevron (owner round, 2026-10-05): the record IS the
|
||||||
// statement — same container + eyebrow discipline as the answered card
|
// statement — same container + eyebrow discipline as the answered card
|
||||||
// and the dock (which mirrors this in Slice D). The commitment never
|
// and the dock (which mirrors this in Slice D). The commitment never
|
||||||
@@ -64,7 +67,7 @@ export function ProblemCard({ part }: ArtifactPartProps) {
|
|||||||
const statement = typeof input.statement === "string" ? input.statement : "";
|
const statement = typeof input.statement === "string" ? input.statement : "";
|
||||||
return (
|
return (
|
||||||
<div data-testid="problem-card" className="lh-card-enter my-1 rounded-xl border bg-card p-4">
|
<div data-testid="problem-card" className="lh-card-enter my-1 rounded-xl border bg-card p-4">
|
||||||
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">Problem</div>
|
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">{t("Problem")}</div>
|
||||||
<div className="lh-prose text-[15px]">
|
<div className="lh-prose text-[15px]">
|
||||||
<MessageResponse>{repairMathForDisplay(statement)}</MessageResponse>
|
<MessageResponse>{repairMathForDisplay(statement)}</MessageResponse>
|
||||||
</div>
|
</div>
|
||||||
@@ -94,6 +97,7 @@ const OUTCOME_CHIP: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function ConcludeCard({ part }: ArtifactPartProps) {
|
export function ConcludeCard({ part }: ArtifactPartProps) {
|
||||||
|
const { t } = useI18n();
|
||||||
// The conclusion redesigned (owner round, 2026-10-05): the SOLUTION is
|
// The conclusion redesigned (owner round, 2026-10-05): the SOLUTION is
|
||||||
// the hero — the modelSolution committed at pose time (part.reveal, the
|
// the hero — the modelSolution committed at pose time (part.reveal, the
|
||||||
// view-seam join), shown verbatim, never re-authored. The verdict sits
|
// view-seam join), shown verbatim, never re-authored. The verdict sits
|
||||||
@@ -111,8 +115,7 @@ export function ConcludeCard({ part }: ArtifactPartProps) {
|
|||||||
if (outcome === "dismissed") {
|
if (outcome === "dismissed") {
|
||||||
return (
|
return (
|
||||||
<div data-testid="conclude-card" className="my-1 px-1 text-sm text-muted-foreground/70">
|
<div data-testid="conclude-card" className="my-1 px-1 text-sm text-muted-foreground/70">
|
||||||
Problem dismissed.
|
{t("Problem dismissed.")}</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,10 +125,9 @@ export function ConcludeCard({ part }: ArtifactPartProps) {
|
|||||||
eyebrow + the outcome in the quiz cards' EXACT chip register —
|
eyebrow + the outcome in the quiz cards' EXACT chip register —
|
||||||
no more two same-weight headings stacked. */}
|
no more two same-weight headings stacked. */}
|
||||||
<div className="mb-1 flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
<div className="mb-1 flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
Concluded
|
{t("Concluded")}{(OUTCOME_LABEL[outcome] ?? "") !== "" && outcome !== "dismissed" && (
|
||||||
{(OUTCOME_LABEL[outcome] ?? "") !== "" && outcome !== "dismissed" && (
|
|
||||||
<span className={cn("rounded px-1.5 py-0.5", OUTCOME_CHIP[outcome] ?? OUTCOME_CHIP.movedOn)}>
|
<span className={cn("rounded px-1.5 py-0.5", OUTCOME_CHIP[outcome] ?? OUTCOME_CHIP.movedOn)}>
|
||||||
{OUTCOME_LABEL[outcome]}
|
{t(OUTCOME_LABEL[outcome] ?? outcome)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -142,7 +144,7 @@ export function ConcludeCard({ part }: ArtifactPartProps) {
|
|||||||
<>
|
<>
|
||||||
<div className="my-3 border-t" />
|
<div className="my-3 border-t" />
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-0.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">Model solution</div>
|
<div className="mb-0.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">{t("Model solution")}</div>
|
||||||
<div className="lh-prose text-[15px]">
|
<div className="lh-prose text-[15px]">
|
||||||
<MessageResponse>{repairMathForDisplay(reveal)}</MessageResponse>
|
<MessageResponse>{repairMathForDisplay(reveal)}</MessageResponse>
|
||||||
</div>
|
</div>
|
||||||
@@ -177,6 +179,7 @@ function MetadataPopover({
|
|||||||
concludeInput: { outcome?: string; rung?: unknown; missClass?: unknown; retest?: unknown };
|
concludeInput: { outcome?: string; rung?: unknown; missClass?: unknown; retest?: unknown };
|
||||||
problemInput: { mode?: unknown; target?: unknown };
|
problemInput: { mode?: unknown; target?: unknown };
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
||||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||||
@@ -239,8 +242,7 @@ function MetadataPopover({
|
|||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
className="flex items-center gap-1 text-xs font-medium uppercase tracking-wide text-muted-foreground transition-colors hover:text-foreground"
|
className="flex items-center gap-1 text-xs font-medium uppercase tracking-wide text-muted-foreground transition-colors hover:text-foreground"
|
||||||
>
|
>
|
||||||
Metadata
|
{t("Metadata")}</button>
|
||||||
</button>
|
|
||||||
{open &&
|
{open &&
|
||||||
anchor &&
|
anchor &&
|
||||||
createPortal(
|
createPortal(
|
||||||
@@ -256,12 +258,12 @@ function MetadataPopover({
|
|||||||
className="z-50 w-80 max-w-[calc(100vw-1rem)] rounded-xl border bg-card p-4 text-left shadow-lg"
|
className="z-50 w-80 max-w-[calc(100vw-1rem)] rounded-xl border bg-card p-4 text-left shadow-lg"
|
||||||
>
|
>
|
||||||
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-xs">
|
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-xs">
|
||||||
<dt className="text-muted-foreground">Mode</dt>
|
<dt className="text-muted-foreground">{t("Mode")}</dt>
|
||||||
<dd className="font-mono">{mode || "—"}</dd>
|
<dd className="font-mono">{t(mode || "—")}</dd>
|
||||||
<dt className="text-muted-foreground">Targets</dt>
|
<dt className="text-muted-foreground">{t("Targets")}</dt>
|
||||||
<dd className="font-mono">
|
<dd className="font-mono">
|
||||||
{targets.length === 0 ? (
|
{targets.length === 0 ? (
|
||||||
"untagged"
|
t("untagged")
|
||||||
) : (
|
) : (
|
||||||
<span>
|
<span>
|
||||||
{targets.map((t, i) => (
|
{targets.map((t, i) => (
|
||||||
@@ -273,14 +275,14 @@ function MetadataPopover({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</dd>
|
</dd>
|
||||||
<dt className="text-muted-foreground">Outcome</dt>
|
<dt className="text-muted-foreground">{t("Outcome")}</dt>
|
||||||
<dd className="font-mono">{outcome || "—"}</dd>
|
<dd className="font-mono">{t(outcome || "—")}</dd>
|
||||||
<dt className="text-muted-foreground">Hint rung</dt>
|
<dt className="text-muted-foreground">{t("Hint rung")}</dt>
|
||||||
<dd className="font-mono">{rung || "none"}</dd>
|
<dd className="font-mono">{t(rung || "none")}</dd>
|
||||||
<dt className="text-muted-foreground">Miss class</dt>
|
<dt className="text-muted-foreground">{t("Miss class")}</dt>
|
||||||
<dd className="font-mono">{miss || "none"}</dd>
|
<dd className="font-mono">{t(miss || "none")}</dd>
|
||||||
<dt className="text-muted-foreground">Retest</dt>
|
<dt className="text-muted-foreground">{t("Retest")}</dt>
|
||||||
<dd className="font-mono">{retest === undefined ? "—" : retest ? "yes" : "no"}</dd>
|
<dd className="font-mono">{retest === undefined ? "—" : retest ? t("yes") : t("no")}</dd>
|
||||||
</dl>
|
</dl>
|
||||||
</div>,
|
</div>,
|
||||||
document.body,
|
document.body,
|
||||||
@@ -290,9 +292,10 @@ function MetadataPopover({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ThinkingCard({ data }: { data: PracticeThinkingData }) {
|
export function ThinkingCard({ data }: { data: PracticeThinkingData }) {
|
||||||
|
const { t } = useI18n();
|
||||||
// The unified row (owner rounds, 2026-10-05): one style, one label —
|
// The unified row (owner rounds, 2026-10-05): one style, one label —
|
||||||
// 50%-italic quote block on expand. Legacy records' commit flag is
|
// 50%-italic quote block on expand. Legacy records' commit flag is
|
||||||
// deliberately unread (one lane now). A TRANSCRIPT row (owner round,
|
// deliberately unread (one lane now). A TRANSCRIPT row (owner round,
|
||||||
// 2026-10-07): full-width band + rail, arriving expanded.
|
// 2026-10-07): full-width band + rail, arriving expanded.
|
||||||
return <ThinkingBlock testId="thinking-card" label="YOUR THINKING" text={data.text} defaultOpen />;
|
return <ThinkingBlock testId="thinking-card" label={t("YOUR THINKING")} text={data.text} defaultOpen />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { useI18n } from "@/components/i18n";
|
||||||
|
|
||||||
|
|
||||||
import { useCallback, useRef, useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
import { ChevronRightIcon } from "lucide-react";
|
import { ChevronRightIcon } from "lucide-react";
|
||||||
@@ -50,6 +52,7 @@ export interface PracticeDockProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PracticeDock({ problemCallId, input, expanded, onExpand, onSubmit, onEscape, compactionRunning = false }: PracticeDockProps) {
|
export function PracticeDock({ problemCallId, input, expanded, onExpand, onSubmit, onEscape, compactionRunning = false }: PracticeDockProps) {
|
||||||
|
const { t } = useI18n();
|
||||||
const [thinking, setThinking] = useState("");
|
const [thinking, setThinking] = useState("");
|
||||||
// Ref MIRRORS the field ("nothing gets lost", the question-dock
|
// Ref MIRRORS the field ("nothing gets lost", the question-dock
|
||||||
// discipline): a submit resuming after `await finishDictation()` reads
|
// discipline): a submit resuming after `await finishDictation()` reads
|
||||||
@@ -93,7 +96,7 @@ export function PracticeDock({ problemCallId, input, expanded, onExpand, onSubmi
|
|||||||
className="flex w-full cursor-pointer items-center gap-2 px-4 py-3 text-left text-sm text-muted-foreground transition-colors hover:text-foreground"
|
className="flex w-full cursor-pointer items-center gap-2 px-4 py-3 text-left text-sm text-muted-foreground transition-colors hover:text-foreground"
|
||||||
>
|
>
|
||||||
<ChevronRightIcon className="size-4 shrink-0 rotate-90" />
|
<ChevronRightIcon className="size-4 shrink-0 rotate-90" />
|
||||||
<span className="font-medium">Problem</span>
|
<span className="font-medium">{t("Problem")}</span>
|
||||||
<span className="min-w-0 flex-1 truncate">{input.statement}</span>
|
<span className="min-w-0 flex-1 truncate">{input.statement}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -107,7 +110,7 @@ export function PracticeDock({ problemCallId, input, expanded, onExpand, onSubmi
|
|||||||
markdown + LaTeX rendered, capped by the measured column
|
markdown + LaTeX rendered, capped by the measured column
|
||||||
(--dock-max-h; the old fixed 40vh cut long problems short). */}
|
(--dock-max-h; the old fixed 40vh cut long problems short). */}
|
||||||
<div className="max-h-[var(--dock-max-h,40vh)] overflow-y-auto px-4 pt-3 pb-2">
|
<div className="max-h-[var(--dock-max-h,40vh)] overflow-y-auto px-4 pt-3 pb-2">
|
||||||
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">Problem</div>
|
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">{t("Problem")}</div>
|
||||||
<div className="lh-prose text-[15px]">
|
<div className="lh-prose text-[15px]">
|
||||||
<MessageResponse>{typeof input.statement === "string" ? repairMathForDisplay(input.statement) : input.statement}</MessageResponse>
|
<MessageResponse>{typeof input.statement === "string" ? repairMathForDisplay(input.statement) : input.statement}</MessageResponse>
|
||||||
</div>
|
</div>
|
||||||
@@ -120,8 +123,7 @@ export function PracticeDock({ problemCallId, input, expanded, onExpand, onSubmi
|
|||||||
className="m-1.5 rounded-[10px] border border-border/70 bg-background/60 transition-colors focus-within:border-primary/50"
|
className="m-1.5 rounded-[10px] border border-border/70 bg-background/60 transition-colors focus-within:border-primary/50"
|
||||||
>
|
>
|
||||||
<div className="px-2.5 pt-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
<div className="px-2.5 pt-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
YOUR THINKING
|
{t("YOUR THINKING")}</div>
|
||||||
</div>
|
|
||||||
<textarea
|
<textarea
|
||||||
value={thinking}
|
value={thinking}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -144,7 +146,7 @@ export function PracticeDock({ problemCallId, input, expanded, onExpand, onSubmi
|
|||||||
submit();
|
submit();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="Work here, think out loud."
|
placeholder={t("Work here, think out loud.")}
|
||||||
rows={4}
|
rows={4}
|
||||||
className={cn(
|
className={cn(
|
||||||
"field-sizing-content lh-input max-h-64 min-h-16 w-full resize-none bg-transparent px-2.5 pb-2 outline-none placeholder:text-muted-foreground",
|
"field-sizing-content lh-input max-h-64 min-h-16 w-full resize-none bg-transparent px-2.5 pb-2 outline-none placeholder:text-muted-foreground",
|
||||||
@@ -156,7 +158,7 @@ export function PracticeDock({ problemCallId, input, expanded, onExpand, onSubmi
|
|||||||
field 10 − p-1 (4) = button 6. */}
|
field 10 − p-1 (4) = button 6. */}
|
||||||
<div className="flex items-center justify-between gap-2 p-1">
|
<div className="flex items-center justify-between gap-2 p-1">
|
||||||
<span aria-hidden className="pl-1.5 text-xs text-muted-foreground">
|
<span aria-hidden className="pl-1.5 text-xs text-muted-foreground">
|
||||||
{compactionRunning ? "compacting session…" : "↵ submits · ⇧↵ newline"}
|
{compactionRunning ? t("compacting session…") : t("↵ submits · ⇧↵ newline")}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -165,8 +167,7 @@ export function PracticeDock({ problemCallId, input, expanded, onExpand, onSubmi
|
|||||||
onClick={() => submit()}
|
onClick={() => submit()}
|
||||||
className="ml-auto h-9 rounded-[6px] px-3.5 text-sm font-medium"
|
className="ml-auto h-9 rounded-[6px] px-3.5 text-sm font-medium"
|
||||||
>
|
>
|
||||||
Submit thinking
|
{t("Submit thinking")}</Button>
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { useI18n } from "@/components/i18n";
|
||||||
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { ArrowDownIcon, ChevronRightIcon, PlayIcon } from "lucide-react";
|
import { ArrowDownIcon, ChevronRightIcon, PlayIcon } from "lucide-react";
|
||||||
@@ -105,6 +107,7 @@ export function QuestionDock({
|
|||||||
dictationClaim = true,
|
dictationClaim = true,
|
||||||
compactionRunning = false,
|
compactionRunning = false,
|
||||||
}: QuestionDockProps) {
|
}: QuestionDockProps) {
|
||||||
|
const { t } = useI18n();
|
||||||
// The probe-phase names (owner, 2026-10-09) canonicalize onto their
|
// The probe-phase names (owner, 2026-10-09) canonicalize onto their
|
||||||
// teach twins for every dock concern — parse dispatch, labels, kinds:
|
// teach twins for every dock concern — parse dispatch, labels, kinds:
|
||||||
// the probe quiz ("probe") is quiz-shaped, the probe free-recall
|
// the probe quiz ("probe") is quiz-shaped, the probe free-recall
|
||||||
@@ -114,7 +117,7 @@ export function QuestionDock({
|
|||||||
// The chip's display name carries the tool's OWN name (owner
|
// The chip's display name carries the tool's OWN name (owner
|
||||||
// 2026-10-09): probe-phase questions label Probe/Recall in the
|
// 2026-10-09): probe-phase questions label Probe/Recall in the
|
||||||
// composer; teach tools keep the standing labels.
|
// composer; teach tools keep the standing labels.
|
||||||
const toolLabel = toolName === "probe" ? "Probe" : toolName === "recall" ? "Recall" : undefined;
|
const toolLabel = toolName === "probe" ? t("Probe") : toolName === "recall" ? t("Recall") : undefined;
|
||||||
// Parse, don't cast (src/core/tool-parts.ts): the model authored this
|
// Parse, don't cast (src/core/tool-parts.ts): the model authored this
|
||||||
// input, and only the schema the tool declared vouches for its shape.
|
// input, and only the schema the tool declared vouches for its shape.
|
||||||
// The dock mounts on input-available parts (already validated at the
|
// The dock mounts on input-available parts (already validated at the
|
||||||
@@ -126,8 +129,7 @@ export function QuestionDock({
|
|||||||
if (!parsed) {
|
if (!parsed) {
|
||||||
return (
|
return (
|
||||||
<div data-testid="malformed-question" className="rounded-xl border border-dashed bg-card/60 p-3 text-sm text-muted-foreground">
|
<div data-testid="malformed-question" className="rounded-xl border border-dashed bg-card/60 p-3 text-sm text-muted-foreground">
|
||||||
This question arrived malformed — the transcript keeps the retry.
|
{t("This question arrived malformed — the transcript keeps the retry.")}</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
@@ -142,7 +144,7 @@ export function QuestionDock({
|
|||||||
label={
|
label={
|
||||||
(canonical === "quiz" || canonical === "explain") &&
|
(canonical === "quiz" || canonical === "explain") &&
|
||||||
normalizeQuizType((parsed as { type?: unknown }).type) === "predict"
|
normalizeQuizType((parsed as { type?: unknown }).type) === "predict"
|
||||||
? "Prediction"
|
? t("Prediction")
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -201,6 +203,7 @@ export function QuestionDockBar({
|
|||||||
* label Probe/Recall — the phase boundary visible in the composer. */
|
* label Probe/Recall — the phase boundary visible in the composer. */
|
||||||
toolLabel?: string;
|
toolLabel?: string;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
if (variant === "reveal") {
|
if (variant === "reveal") {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -213,11 +216,11 @@ export function QuestionDockBar({
|
|||||||
<PlayIcon className="size-3.5" />
|
<PlayIcon className="size-3.5" />
|
||||||
</span>
|
</span>
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
<span className="block text-sm font-medium">Check your understanding</span>
|
<span className="block text-sm font-medium">{t("Check your understanding")}</span>
|
||||||
<span className="block truncate text-xs text-muted-foreground">{teaserText(question)}</span>
|
<span className="block truncate text-xs text-muted-foreground">{teaserText(question)}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="flex shrink-0 items-center gap-1 text-xs font-medium text-primary">
|
<span className="flex shrink-0 items-center gap-1 text-xs font-medium text-primary">
|
||||||
Open <ChevronRightIcon className="size-3.5" />
|
{t("Open")}{" "}<ChevronRightIcon className="size-3.5" />
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -232,11 +235,11 @@ export function QuestionDockBar({
|
|||||||
<span className="shrink-0 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
<span className="shrink-0 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
{label ??
|
{label ??
|
||||||
toolLabel ??
|
toolLabel ??
|
||||||
(toolName === "quiz" ? "Check" : toolName === "explain" ? "Explain" : "Question")}
|
(toolName === "quiz" ? t("Check") : toolName === "explain" ? t("Explain") : t("Question"))}
|
||||||
</span>
|
</span>
|
||||||
<span className="min-w-0 flex-1 truncate text-muted-foreground">{teaserText(question)}</span>
|
<span className="min-w-0 flex-1 truncate text-muted-foreground">{teaserText(question)}</span>
|
||||||
<span className="flex shrink-0 items-center gap-1 text-xs font-medium">
|
<span className="flex shrink-0 items-center gap-1 text-xs font-medium">
|
||||||
Answer <ArrowDownIcon className="size-3.5" />
|
{t("Answer")}{" "}<ArrowDownIcon className="size-3.5" />
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -254,6 +257,7 @@ function QuestionDockForm({
|
|||||||
dictationClaim = true,
|
dictationClaim = true,
|
||||||
compactionRunning = false,
|
compactionRunning = false,
|
||||||
}: Omit<QuestionDockProps, "expanded" | "onExpand" | "reveal">) {
|
}: Omit<QuestionDockProps, "expanded" | "onExpand" | "reveal">) {
|
||||||
|
const { t } = useI18n();
|
||||||
const kind = toolQuestionKind(toolName);
|
const kind = toolQuestionKind(toolName);
|
||||||
|
|
||||||
// Canonical QuestionData, built once per question. Quiz options are
|
// Canonical QuestionData, built once per question. Quiz options are
|
||||||
@@ -547,17 +551,17 @@ function QuestionDockForm({
|
|||||||
<div data-session-find-root="" className="max-h-[var(--dock-max-h,55dvh)] overflow-y-auto px-4 pt-3">
|
<div data-session-find-root="" className="max-h-[var(--dock-max-h,55dvh)] overflow-y-auto px-4 pt-3">
|
||||||
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
{toolName === "probe"
|
{toolName === "probe"
|
||||||
? "Probe"
|
? t("Probe")
|
||||||
: toolName === "recall"
|
: toolName === "recall"
|
||||||
? "Recall"
|
? t("Recall")
|
||||||
: kind === "quiz"
|
: kind === "quiz"
|
||||||
? qd.questionType === "predict"
|
? qd.questionType === "predict"
|
||||||
? "Prediction"
|
? t("Prediction")
|
||||||
: "Check"
|
: t("Check")
|
||||||
: kind === "explain"
|
: kind === "explain"
|
||||||
? "Explain"
|
? t("Explain")
|
||||||
: "Question"}
|
: t("Question")}
|
||||||
{qd.mode === "multi-select" && " · select all that apply"}
|
{qd.mode === "multi-select" && t(" · select all that apply")}
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-3 font-medium">
|
<div className="mb-3 font-medium">
|
||||||
<MessageResponse>{qd.question}</MessageResponse>
|
<MessageResponse>{qd.question}</MessageResponse>
|
||||||
@@ -644,7 +648,7 @@ function QuestionDockForm({
|
|||||||
>
|
>
|
||||||
{sel.dontKnow && <span className="size-1.5 rounded-full bg-current" />}
|
{sel.dontKnow && <span className="size-1.5 rounded-full bg-current" />}
|
||||||
</span>
|
</span>
|
||||||
<span>I don't know</span>
|
<span>{t("I don't know")}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -710,7 +714,7 @@ function QuestionDockForm({
|
|||||||
submit();
|
submit();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="Type your own answer…"
|
placeholder={t("Type your own answer…")}
|
||||||
className="lh-input w-full min-w-0 flex-1 bg-transparent outline-none placeholder:text-muted-foreground"
|
className="lh-input w-full min-w-0 flex-1 bg-transparent outline-none placeholder:text-muted-foreground"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
/>
|
/>
|
||||||
@@ -742,8 +746,7 @@ function QuestionDockForm({
|
|||||||
sel.dontKnow ? "text-muted-foreground/50" : "text-muted-foreground",
|
sel.dontKnow ? "text-muted-foreground/50" : "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
Confidence
|
{t("Confidence")}</span>
|
||||||
</span>
|
|
||||||
<div
|
<div
|
||||||
className={cn("relative h-4 min-w-0 flex-1", sel.dontKnow && "opacity-40")}
|
className={cn("relative h-4 min-w-0 flex-1", sel.dontKnow && "opacity-40")}
|
||||||
data-testid="confidence-track"
|
data-testid="confidence-track"
|
||||||
@@ -771,9 +774,9 @@ function QuestionDockForm({
|
|||||||
step={1}
|
step={1}
|
||||||
value={CONFIDENCE_STOPS[confIdx]}
|
value={CONFIDENCE_STOPS[confIdx]}
|
||||||
disabled={sel.dontKnow}
|
disabled={sel.dontKnow}
|
||||||
aria-label="Confidence — the probability your selected answer is correct"
|
aria-label={t("Confidence — the probability your selected answer is correct")}
|
||||||
aria-valuetext={`${CONFIDENCE_STOPS[confIdx]} percent`}
|
aria-valuetext={t("{v0} percent", { v0: CONFIDENCE_STOPS[confIdx] })}
|
||||||
title="The probability your selected answer is correct — 50% is a coin flip. Adjust with ←/→."
|
title={t("The probability your selected answer is correct — 50% is a coin flip. Adjust with ←/→.")}
|
||||||
data-testid="confidence-slider"
|
data-testid="confidence-slider"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
// Snap to the nearest stop; the rendered VALUE is the stop's own
|
// Snap to the nearest stop; the rendered VALUE is the stop's own
|
||||||
@@ -828,7 +831,7 @@ function QuestionDockForm({
|
|||||||
className="rounded-[10px] border border-border/70 bg-background/60 transition-colors focus-within:border-primary/50"
|
className="rounded-[10px] border border-border/70 bg-background/60 transition-colors focus-within:border-primary/50"
|
||||||
>
|
>
|
||||||
<div className="px-2.5 pt-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
<div className="px-2.5 pt-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
{qd.mode === "text" ? "Your answer" : "YOUR THINKING"}
|
{qd.mode === "text" ? t("Your answer") : t("YOUR THINKING")}
|
||||||
</div>
|
</div>
|
||||||
{qd.mode === "text" ? (
|
{qd.mode === "text" ? (
|
||||||
<textarea
|
<textarea
|
||||||
@@ -854,7 +857,7 @@ function QuestionDockForm({
|
|||||||
submit();
|
submit();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="Type your answer…"
|
placeholder={t("Type your answer…")}
|
||||||
rows={1}
|
rows={1}
|
||||||
className="field-sizing-content lh-input max-h-48 min-h-10 w-full resize-none bg-transparent px-2.5 py-2 outline-none placeholder:text-muted-foreground"
|
className="field-sizing-content lh-input max-h-48 min-h-10 w-full resize-none bg-transparent px-2.5 py-2 outline-none placeholder:text-muted-foreground"
|
||||||
/>
|
/>
|
||||||
@@ -884,7 +887,7 @@ function QuestionDockForm({
|
|||||||
submit();
|
submit();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="Why this answer? Whatever comes to mind."
|
placeholder={t("Why this answer? Whatever comes to mind.")}
|
||||||
rows={1}
|
rows={1}
|
||||||
className={cn(
|
className={cn(
|
||||||
"field-sizing-content lh-input max-h-48 w-full resize-none bg-transparent px-2.5 py-2 outline-none placeholder:text-muted-foreground/70",
|
"field-sizing-content lh-input max-h-48 w-full resize-none bg-transparent px-2.5 py-2 outline-none placeholder:text-muted-foreground/70",
|
||||||
@@ -896,15 +899,14 @@ function QuestionDockForm({
|
|||||||
)}
|
)}
|
||||||
<div className="flex items-center justify-between gap-2 p-1">
|
<div className="flex items-center justify-between gap-2 p-1">
|
||||||
<span aria-hidden className="pl-1.5 text-xs text-muted-foreground">
|
<span aria-hidden className="pl-1.5 text-xs text-muted-foreground">
|
||||||
{compactionRunning ? "compacting session…" : "↵ submits · ⇧↵ newline"}
|
{compactionRunning ? t("compacting session…") : t("↵ submits · ⇧↵ newline")}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!canSubmit || compactionRunning}
|
disabled={!canSubmit || compactionRunning}
|
||||||
className="ml-auto h-9 rounded-[6px] px-3.5 text-sm font-medium"
|
className="ml-auto h-9 rounded-[6px] px-3.5 text-sm font-medium"
|
||||||
>
|
>
|
||||||
Submit answer
|
{t("Submit answer")}</Button>
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { useI18n } from "@/components/i18n";
|
||||||
|
|
||||||
|
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { joinDictation, useDictationTarget } from "@/components/dictation";
|
import { joinDictation, useDictationTarget } from "@/components/dictation";
|
||||||
@@ -37,6 +39,7 @@ export function ReadingThinkingField({
|
|||||||
/** Primary dictation claim — true while the check is behind its reveal. */
|
/** Primary dictation claim — true while the check is behind its reveal. */
|
||||||
claim: boolean;
|
claim: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
useDictationTarget(
|
useDictationTarget(
|
||||||
useCallback(
|
useCallback(
|
||||||
(t: string) => {
|
(t: string) => {
|
||||||
@@ -52,8 +55,7 @@ export function ReadingThinkingField({
|
|||||||
className="pointer-events-auto mb-2 rounded-2xl border bg-card/60 px-3 py-2 transition-colors focus-within:border-primary/50"
|
className="pointer-events-auto mb-2 rounded-2xl border bg-card/60 px-3 py-2 transition-colors focus-within:border-primary/50"
|
||||||
>
|
>
|
||||||
<div className="pb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
<div className="pb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
Thinking while reading
|
{t("Thinking while reading")}</div>
|
||||||
</div>
|
|
||||||
<textarea
|
<textarea
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
@@ -94,7 +96,7 @@ export function ReadingThinkingField({
|
|||||||
e.currentTarget.blur();
|
e.currentTarget.blur();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="Whatever comes to mind as you read."
|
placeholder={t("Whatever comes to mind as you read.")}
|
||||||
rows={1}
|
rows={1}
|
||||||
className="field-sizing-content lh-input max-h-40 w-full resize-none bg-transparent py-0.5 outline-none placeholder:text-muted-foreground/70"
|
className="field-sizing-content lh-input max-h-40 w-full resize-none bg-transparent py-0.5 outline-none placeholder:text-muted-foreground/70"
|
||||||
/>
|
/>
|
||||||
|
|||||||
+50
-19
@@ -17,6 +17,7 @@ import type { AgentEvent } from "@earendil-works/pi-agent-core";
|
|||||||
import type { ToolResultMessage } from "@earendil-works/pi-ai";
|
import type { ToolResultMessage } from "@earendil-works/pi-ai";
|
||||||
import {
|
import {
|
||||||
needsContinuation,
|
needsContinuation,
|
||||||
|
measuredContextTokens,
|
||||||
newAnswerResult,
|
newAnswerResult,
|
||||||
newNextMessage,
|
newNextMessage,
|
||||||
newProblemDismissedMarker,
|
newProblemDismissedMarker,
|
||||||
@@ -163,6 +164,8 @@ function sameDetails(a: unknown, b: unknown): boolean {
|
|||||||
export interface SessionState {
|
export interface SessionState {
|
||||||
messages: LhViewMessage[];
|
messages: LhViewMessage[];
|
||||||
nativeMessages: LhMessage[];
|
nativeMessages: LhMessage[];
|
||||||
|
/** Completed conversation usage; null after compaction or before first usage. */
|
||||||
|
contextTokens: number | null;
|
||||||
status: SessionStatus;
|
status: SessionStatus;
|
||||||
error: { message: string } | null;
|
error: { message: string } | null;
|
||||||
/** Steers sent but not yet drained (composer steering, 2026-10-03):
|
/** Steers sent but not yet drained (composer steering, 2026-10-03):
|
||||||
@@ -220,10 +223,14 @@ export class SessionModel {
|
|||||||
// The live turn's execution layer (view model mid-turn states):
|
// The live turn's execution layer (view model mid-turn states):
|
||||||
private executions = new Map<string, LiveExecution>();
|
private executions = new Map<string, LiveExecution>();
|
||||||
private streamingIds = new Set<string>();
|
private streamingIds = new Set<string>();
|
||||||
|
private incompleteUsageIds = new Set<string>();
|
||||||
/** The in-flight assistant message's client id (see applyWireEvent). */
|
/** The in-flight assistant message's client id (see applyWireEvent). */
|
||||||
private streamingMessageId: string | null = null;
|
private streamingMessageId: string | null = null;
|
||||||
private statusValue: SessionStatus = "ready";
|
private statusValue: SessionStatus = "ready";
|
||||||
|
/** Request/loop failures are transient; provider failures derive from messages. */
|
||||||
private errorValue: { message: string } | null = null;
|
private errorValue: { message: string } | null = null;
|
||||||
|
/** A completed failure from THIS consume, not the previous attempt's record. */
|
||||||
|
private liveFailureId: string | null = null;
|
||||||
// The single live consume (POST response or reattach stream): a newer
|
// The single live consume (POST response or reattach stream): a newer
|
||||||
// consume cancels the older — one reader at a time.
|
// consume cancels the older — one reader at a time.
|
||||||
private consumeAbort: AbortController | null = null;
|
private consumeAbort: AbortController | null = null;
|
||||||
@@ -254,11 +261,22 @@ export class SessionModel {
|
|||||||
this.statusValue === "submitted" || this.statusValue === "streaming"
|
this.statusValue === "submitted" || this.statusValue === "streaming"
|
||||||
? { executions: this.executions, inFlight: true, streamingIds: this.streamingIds }
|
? { executions: this.executions, inFlight: true, streamingIds: this.streamingIds }
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const messages = [...this.base, ...this.tail];
|
||||||
|
const latest = messages.at(-1);
|
||||||
|
// One rule for live and restored failures: only the final completed
|
||||||
|
// assistant can own the banner. While trying again, hide the old
|
||||||
|
// failure from the first Thinking frame, but show a new failure at end.
|
||||||
|
const providerError =
|
||||||
|
latest?.role === "assistant" && latest.stopReason === "error" &&
|
||||||
|
!this.incompleteUsageIds.has(latest.id) && (!live || latest.id === this.liveFailureId)
|
||||||
|
? { message: latest.errorMessage ?? "An error occurred." }
|
||||||
|
: null;
|
||||||
return {
|
return {
|
||||||
messages: projectView([...this.base, ...this.tail], live),
|
messages: projectView(messages, live),
|
||||||
nativeMessages: [...this.base, ...this.tail],
|
nativeMessages: messages,
|
||||||
|
contextTokens: measuredContextTokens(messages, this.incompleteUsageIds),
|
||||||
status: this.statusValue,
|
status: this.statusValue,
|
||||||
error: this.errorValue,
|
error: this.errorValue ?? providerError,
|
||||||
steerQueue: this.steerQueueValue,
|
steerQueue: this.steerQueueValue,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -274,6 +292,7 @@ export class SessionModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private setStatus(status: SessionStatus): void {
|
private setStatus(status: SessionStatus): void {
|
||||||
|
if (status === "submitted") this.liveFailureId = null;
|
||||||
this.statusValue = status;
|
this.statusValue = status;
|
||||||
this.readyRef = status === "ready";
|
this.readyRef = status === "ready";
|
||||||
this.onChange?.();
|
this.onChange?.();
|
||||||
@@ -364,6 +383,15 @@ export class SessionModel {
|
|||||||
// the wire) still wins.
|
// the wire) still wins.
|
||||||
const id = message.id ?? this.streamingMessageId ?? localId();
|
const id = message.id ?? this.streamingMessageId ?? localId();
|
||||||
const withId = { ...message, id } as LhMessage;
|
const withId = { ...message, id } as LhMessage;
|
||||||
|
if (agent.type === "message_end") {
|
||||||
|
this.incompleteUsageIds.delete(id);
|
||||||
|
if (message.role === "assistant" && message.stopReason === "error") {
|
||||||
|
this.liveFailureId = id;
|
||||||
|
// A newer provider failure replaces an earlier connection/loop
|
||||||
|
// error, just as the live message_end did before restoration.
|
||||||
|
this.errorValue = null;
|
||||||
|
}
|
||||||
|
} else this.incompleteUsageIds.add(id);
|
||||||
if (agent.type === "message_start") {
|
if (agent.type === "message_start") {
|
||||||
this.streamingMessageId = id;
|
this.streamingMessageId = id;
|
||||||
this.streamingIds.add(id);
|
this.streamingIds.add(id);
|
||||||
@@ -378,15 +406,6 @@ export class SessionModel {
|
|||||||
}
|
}
|
||||||
if (agent.type === "message_end") {
|
if (agent.type === "message_end") {
|
||||||
this.streamingMessageId = null; // slot done — next start mints fresh
|
this.streamingMessageId = null; // slot done — next start mints fresh
|
||||||
// (fallthrough: returns true below — the message landed)
|
|
||||||
const stop = (message as { stopReason?: string }).stopReason;
|
|
||||||
if (stop === "error") {
|
|
||||||
// Provider error, verbatim (owner ruling) — the record
|
|
||||||
// persists the same message; no masking anywhere.
|
|
||||||
this.setError(
|
|
||||||
String((message as { errorMessage?: string }).errorMessage ?? "An error occurred."),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return true; // a message event is view work, even empty content
|
return true; // a message event is view work, even empty content
|
||||||
}
|
}
|
||||||
@@ -426,17 +445,18 @@ export class SessionModel {
|
|||||||
|
|
||||||
/** Consume one SSE body to its [DONE] terminal. The turn-settled
|
/** Consume one SSE body to its [DONE] terminal. The turn-settled
|
||||||
* terminal (stream-route only) fires the settle hook and ends. */
|
* terminal (stream-route only) fires the settle hook and ends. */
|
||||||
async consumeStream(res: Response): Promise<void> {
|
async consumeStream(res: Response, controller = this.consumeAbort): Promise<void> {
|
||||||
if (!res.body) throw new Error("The turn stream body is empty.");
|
if (!res.body) throw new Error("The turn stream body is empty.");
|
||||||
const reader = res.body.getReader();
|
const reader = res.body.getReader();
|
||||||
let sawEvent = false;
|
let sawEvent = false;
|
||||||
try {
|
try {
|
||||||
for await (const event of sseEvents(reader)) {
|
for await (const event of sseEvents(reader)) {
|
||||||
|
if (this.consumeAbort !== controller) return;
|
||||||
if (event.type === "turn-settled") {
|
if (event.type === "turn-settled") {
|
||||||
// The raced-settle terminal: no turn to attach to — the
|
// The raced-settle terminal: no turn to attach to — the
|
||||||
// record is current; the settle hook refetches.
|
// record is current; the settle hook refetches.
|
||||||
await reader.cancel().catch(() => {});
|
await reader.cancel().catch(() => {});
|
||||||
this.onReattachSettled?.();
|
if (this.consumeAbort === controller) this.onReattachSettled?.();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const acted = this.applyWireEvent(event);
|
const acted = this.applyWireEvent(event);
|
||||||
@@ -446,9 +466,13 @@ export class SessionModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
this.streamingIds = new Set();
|
// A cancelled consume may finish after its replacement has entered
|
||||||
this.executions = new Map();
|
// Thinking. Only the current owner can settle shared state.
|
||||||
if (this.statusValue !== "error") this.setStatus("ready");
|
if (this.consumeAbort === controller) {
|
||||||
|
this.streamingIds = new Set();
|
||||||
|
this.executions = new Map();
|
||||||
|
if (this.statusValue !== "error") this.setStatus("ready");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -487,6 +511,7 @@ export class SessionModel {
|
|||||||
// every path that actually replays onto a stale tail).
|
// every path that actually replays onto a stale tail).
|
||||||
if (!refetchRecordFirst) {
|
if (!refetchRecordFirst) {
|
||||||
this.mutate(() => {
|
this.mutate(() => {
|
||||||
|
this.incompleteUsageIds.clear();
|
||||||
this.tail = [];
|
this.tail = [];
|
||||||
this.streamingMessageId = null;
|
this.streamingMessageId = null;
|
||||||
});
|
});
|
||||||
@@ -497,11 +522,14 @@ export class SessionModel {
|
|||||||
const recordRes = await this.fetch(`/api/chats/${encodeURIComponent(this.chatId)}`, {
|
const recordRes = await this.fetch(`/api/chats/${encodeURIComponent(this.chatId)}`, {
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
|
if (this.consumeAbort !== controller) return;
|
||||||
if (recordRes.ok) {
|
if (recordRes.ok) {
|
||||||
const record = (await recordRes.json()) as { messages?: LhMessage[] };
|
const record = (await recordRes.json()) as { messages?: LhMessage[] };
|
||||||
|
if (this.consumeAbort !== controller) return;
|
||||||
if (Array.isArray(record.messages)) {
|
if (Array.isArray(record.messages)) {
|
||||||
this.mutate(() => {
|
this.mutate(() => {
|
||||||
this.base = record.messages!;
|
this.base = record.messages!;
|
||||||
|
this.incompleteUsageIds.clear();
|
||||||
this.tail = [];
|
this.tail = [];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -509,6 +537,7 @@ export class SessionModel {
|
|||||||
// The record did not land — restore the clean-slate guard
|
// The record did not land — restore the clean-slate guard
|
||||||
// before the replay (no atomic swap is coming).
|
// before the replay (no atomic swap is coming).
|
||||||
this.mutate(() => {
|
this.mutate(() => {
|
||||||
|
this.incompleteUsageIds.clear();
|
||||||
this.tail = [];
|
this.tail = [];
|
||||||
this.streamingMessageId = null;
|
this.streamingMessageId = null;
|
||||||
});
|
});
|
||||||
@@ -517,10 +546,11 @@ export class SessionModel {
|
|||||||
const res = await this.fetch(`/api/chats/${encodeURIComponent(this.chatId)}/stream`, {
|
const res = await this.fetch(`/api/chats/${encodeURIComponent(this.chatId)}/stream`, {
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
|
if (this.consumeAbort !== controller) return;
|
||||||
if (!res.ok) throw new Error((await res.text()) || "Failed to attach to the running turn.");
|
if (!res.ok) throw new Error((await res.text()) || "Failed to attach to the running turn.");
|
||||||
await this.consumeStream(res);
|
await this.consumeStream(res, controller);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if ((err as Error).name === "AbortError") return; // a newer consume won
|
if (this.consumeAbort !== controller || (err as Error).name === "AbortError") return; // a newer consume won
|
||||||
this.setError((err as Error).message);
|
this.setError((err as Error).message);
|
||||||
this.setStatus("error");
|
this.setStatus("error");
|
||||||
}
|
}
|
||||||
@@ -714,6 +744,7 @@ export class SessionModel {
|
|||||||
setMessages(record: LhMessage[]): void {
|
setMessages(record: LhMessage[]): void {
|
||||||
this.mutate(() => {
|
this.mutate(() => {
|
||||||
this.base = record;
|
this.base = record;
|
||||||
|
this.incompleteUsageIds.clear();
|
||||||
this.tail = [];
|
this.tail = [];
|
||||||
this.streamingMessageId = null; // the tail (and its slot) is gone
|
this.streamingMessageId = null; // the tail (and its slot) is gone
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { useI18n } from "@/components/i18n";
|
||||||
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
|
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
|
||||||
import { ChevronDownIcon, ChevronUpIcon, SearchIcon, XIcon } from "lucide-react";
|
import { ChevronDownIcon, ChevronUpIcon, SearchIcon, XIcon } from "lucide-react";
|
||||||
@@ -66,6 +68,7 @@ export function SessionFind({ scopeRef, scrollContextRef }: {
|
|||||||
scopeRef: RefObject<HTMLDivElement | null>;
|
scopeRef: RefObject<HTMLDivElement | null>;
|
||||||
scrollContextRef: RefObject<SessionScrollContext | null>;
|
scrollContextRef: RefObject<SessionScrollContext | null>;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [result, setResult] = useState({ index: -1, count: 0 });
|
const [result, setResult] = useState({ index: -1, count: 0 });
|
||||||
@@ -163,7 +166,7 @@ export function SessionFind({ scopeRef, scrollContextRef }: {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role="search"
|
role="search"
|
||||||
aria-label="Session Find"
|
aria-label={t("Session Find")}
|
||||||
data-testid="session-find"
|
data-testid="session-find"
|
||||||
className="lh-no-drag flex h-10 shrink-0 items-center gap-2 border-b border-border px-6 font-ui text-sm"
|
className="lh-no-drag flex h-10 shrink-0 items-center gap-2 border-b border-border px-6 font-ui text-sm"
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
@@ -182,8 +185,8 @@ export function SessionFind({ scopeRef, scrollContextRef }: {
|
|||||||
<SearchIcon className="size-4 shrink-0 text-muted-foreground" aria-hidden />
|
<SearchIcon className="size-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
aria-label="Find in session"
|
aria-label={t("Find in session")}
|
||||||
placeholder="Find in session…"
|
placeholder={t("Find in session…")}
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(event) => setQuery(event.target.value)}
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
@@ -192,15 +195,15 @@ export function SessionFind({ scopeRef, scrollContextRef }: {
|
|||||||
<output aria-live="polite" data-testid="session-find-count" className="shrink-0 text-xs tabular-nums text-muted-foreground">
|
<output aria-live="polite" data-testid="session-find-count" className="shrink-0 text-xs tabular-nums text-muted-foreground">
|
||||||
{query ? `${result.index + 1}/${result.count}` : ""}
|
{query ? `${result.index + 1}/${result.count}` : ""}
|
||||||
</output>
|
</output>
|
||||||
<button type="button" aria-label="Previous match" title="Previous match (Shift+Enter)" className={buttonClass}
|
<button type="button" aria-label={t("Previous match")} title={t("Previous match (Shift+Enter)")} className={buttonClass}
|
||||||
disabled={!result.count} onMouseDown={(event) => event.preventDefault()} onClick={() => step(-1)}>
|
disabled={!result.count} onMouseDown={(event) => event.preventDefault()} onClick={() => step(-1)}>
|
||||||
<ChevronUpIcon className="size-4" aria-hidden />
|
<ChevronUpIcon className="size-4" aria-hidden />
|
||||||
</button>
|
</button>
|
||||||
<button type="button" aria-label="Next match" title="Next match (Enter)" className={buttonClass}
|
<button type="button" aria-label={t("Next match")} title={t("Next match (Enter)")} className={buttonClass}
|
||||||
disabled={!result.count} onMouseDown={(event) => event.preventDefault()} onClick={() => step(1)}>
|
disabled={!result.count} onMouseDown={(event) => event.preventDefault()} onClick={() => step(1)}>
|
||||||
<ChevronDownIcon className="size-4" aria-hidden />
|
<ChevronDownIcon className="size-4" aria-hidden />
|
||||||
</button>
|
</button>
|
||||||
<button type="button" aria-label="Close Find" title="Close Find (Esc)" className={buttonClass} onClick={close}>
|
<button type="button" aria-label={t("Close Find")} title={t("Close Find (Esc)")} className={buttonClass} onClick={close}>
|
||||||
<XIcon className="size-4" aria-hidden />
|
<XIcon className="size-4" aria-hidden />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { useI18n } from "@/components/i18n";
|
||||||
|
|
||||||
|
|
||||||
import { createContext, useContext, useEffect, useMemo, useRef, useState } from "react";
|
import { createContext, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { MaximizeIcon, MinusIcon, PlusIcon, XIcon } from "lucide-react";
|
import { MaximizeIcon, MinusIcon, PlusIcon, XIcon } from "lucide-react";
|
||||||
@@ -83,6 +85,7 @@ export function MermaidOverlay({
|
|||||||
inverted?: boolean;
|
inverted?: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
// ── chart render (own instance, own ids); figure mode uses the raw svg ──
|
// ── chart render (own instance, own ids); figure mode uses the raw svg ──
|
||||||
const [svg, setSvg] = useState<string | null>(rawSvg ?? null);
|
const [svg, setSvg] = useState<string | null>(rawSvg ?? null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -218,16 +221,16 @@ export function MermaidOverlay({
|
|||||||
className="absolute inset-0 z-40 flex flex-col bg-background/80 p-4 backdrop-blur-sm"
|
className="absolute inset-0 z-40 flex flex-col bg-background/80 p-4 backdrop-blur-sm"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label="Diagram focus"
|
aria-label={t("Diagram focus")}
|
||||||
>
|
>
|
||||||
<div className="mb-2 flex items-center gap-2">
|
<div className="mb-2 flex items-center gap-2">
|
||||||
<span className="text-sm font-medium">Diagram</span>
|
<span className="text-sm font-medium">{t("Diagram")}</span>
|
||||||
<span className="text-xs text-muted-foreground">from this lesson</span>
|
<span className="text-xs text-muted-foreground">{t("from this lesson")}</span>
|
||||||
<Hint label="Close" binding="escape" side="left">
|
<Hint label={t("Close")} binding="escape" side="left">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
aria-label="Close"
|
aria-label={t("Close")}
|
||||||
data-testid="mermaid-overlay-close"
|
data-testid="mermaid-overlay-close"
|
||||||
className="ml-auto rounded-lg p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
className="ml-auto rounded-lg p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||||
>
|
>
|
||||||
@@ -287,32 +290,32 @@ export function MermaidOverlay({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-between p-2">
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-between p-2">
|
||||||
<span className="text-xs text-muted-foreground/60">Drag to pan · scroll to zoom</span>
|
<span className="text-xs text-muted-foreground/60">{t("Drag to pan · scroll to zoom")}</span>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<Hint label="Zoom out" side="bottom">
|
<Hint label={t("Zoom out")} side="bottom">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Zoom out"
|
aria-label={t("Zoom out")}
|
||||||
onClick={() => stepZoom(1 / 1.25)}
|
onClick={() => stepZoom(1 / 1.25)}
|
||||||
className="pointer-events-auto flex size-7 items-center justify-center rounded-md border border-border/70 bg-card/80 text-muted-foreground backdrop-blur transition-colors hover:bg-accent hover:text-foreground"
|
className="pointer-events-auto flex size-7 items-center justify-center rounded-md border border-border/70 bg-card/80 text-muted-foreground backdrop-blur transition-colors hover:bg-accent hover:text-foreground"
|
||||||
>
|
>
|
||||||
<MinusIcon className="size-3.5" />
|
<MinusIcon className="size-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</Hint>
|
</Hint>
|
||||||
<Hint label="Fit diagram to view" side="bottom">
|
<Hint label={t("Fit diagram to view")} side="bottom">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Fit diagram to view"
|
aria-label={t("Fit diagram to view")}
|
||||||
onClick={fitView}
|
onClick={fitView}
|
||||||
className="pointer-events-auto flex size-7 items-center justify-center rounded-md border border-border/70 bg-card/80 text-muted-foreground backdrop-blur transition-colors hover:bg-accent hover:text-foreground"
|
className="pointer-events-auto flex size-7 items-center justify-center rounded-md border border-border/70 bg-card/80 text-muted-foreground backdrop-blur transition-colors hover:bg-accent hover:text-foreground"
|
||||||
>
|
>
|
||||||
<MaximizeIcon className="size-3.5" />
|
<MaximizeIcon className="size-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</Hint>
|
</Hint>
|
||||||
<Hint label="Zoom in" side="bottom">
|
<Hint label={t("Zoom in")} side="bottom">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Zoom in"
|
aria-label={t("Zoom in")}
|
||||||
onClick={() => stepZoom(1.25)}
|
onClick={() => stepZoom(1.25)}
|
||||||
className="pointer-events-auto flex size-7 items-center justify-center rounded-md border border-border/70 bg-card/80 text-muted-foreground backdrop-blur transition-colors hover:bg-accent hover:text-foreground"
|
className="pointer-events-auto flex size-7 items-center justify-center rounded-md border border-border/70 bg-card/80 text-muted-foreground backdrop-blur transition-colors hover:bg-accent hover:text-foreground"
|
||||||
>
|
>
|
||||||
@@ -325,8 +328,7 @@ export function MermaidOverlay({
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground">
|
<div className="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||||
<span className="inline-block size-4 animate-spin rounded-full border border-current border-b-2" />
|
<span className="inline-block size-4 animate-spin rounded-full border border-current border-b-2" />
|
||||||
Rendering diagram…
|
{t("Rendering diagram…")}</div>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ export function useChatSession({ chatId, initialMessages, initiallyInFlight }: U
|
|||||||
return {
|
return {
|
||||||
messages: state.messages,
|
messages: state.messages,
|
||||||
nativeMessages: state.nativeMessages,
|
nativeMessages: state.nativeMessages,
|
||||||
|
contextTokens: state.contextTokens,
|
||||||
status: state.status,
|
status: state.status,
|
||||||
error: state.error,
|
error: state.error,
|
||||||
steerQueue: state.steerQueue,
|
steerQueue: state.steerQueue,
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { useI18n } from "@/components/i18n";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The Agent section (settings-app wave): the THREE MODEL SLOTS, the tool
|
* The Agent section (settings-app wave): the THREE MODEL SLOTS, the tool
|
||||||
* toggles + the search-engine preference, and the prompt override
|
* toggles + the search-engine preference, and the prompt override
|
||||||
@@ -70,8 +72,12 @@ import {
|
|||||||
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
||||||
import { prettyModel } from "@/components/usage-format";
|
import { prettyModel } from "@/components/usage-format";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { ZapIcon } from "lucide-react";
|
||||||
|
import { Hint } from "@/components/ui/hint";
|
||||||
import { SettingsGroup, SettingsRow, Toggle, CommitInput } from "./controls.tsx";
|
import { SettingsGroup, SettingsRow, Toggle, CommitInput } from "./controls.tsx";
|
||||||
import { ModelMenuList, ThinkingMenuList } from "./model-menu";
|
import { ModelMenuList, ThinkingMenuList } from "./model-menu";
|
||||||
|
import { ChatGPTBackgroundAccess } from "./chatgpt-connection";
|
||||||
|
import { NativeSubscriptionBackgroundAccess } from "./subscription-connections";
|
||||||
|
|
||||||
/** One slot's display identity — the tier's roster as the row hint rides
|
/** One slot's display identity — the tier's roster as the row hint rides
|
||||||
* with the resolved default (owner: the hint ALWAYS names the default). */
|
* with the resolved default (owner: the hint ALWAYS names the default). */
|
||||||
@@ -131,19 +137,6 @@ export function composeGraphTools(agent: AgentSettings | undefined, on: boolean)
|
|||||||
return { ...agent, graphTools: { enabled: on } };
|
return { ...agent, graphTools: { enabled: on } };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The research tool runs on the search port — the teacher registry DROPS
|
|
||||||
* it when no engine is live (chat-handler's searchAvailable, the same
|
|
||||||
* /api/search signal the section fetches). Pure — exported for tests.
|
|
||||||
* Teacher-stream flag 2026-09-22: the settings row grays on the SAME
|
|
||||||
* signal, so the row and the actual registry availability can never
|
|
||||||
* disagree. `search === null` (loading/failed) grays NOTHING. */
|
|
||||||
export function researchToolUnavailable(
|
|
||||||
toolId: string,
|
|
||||||
search: { available: boolean } | null,
|
|
||||||
): boolean {
|
|
||||||
return toolId === "research" && search !== null && !search.available;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Compose: one slot's model/thinking override. Empty model = absent
|
/** Compose: one slot's model/thinking override. Empty model = absent
|
||||||
* override (Default — falls to the factory pin); empty thinking = the
|
* override (Default — falls to the factory pin); empty thinking = the
|
||||||
* tier keeps its resolved thinking budget. Per-field independence, same
|
* tier keeps its resolved thinking budget. Per-field independence, same
|
||||||
@@ -161,13 +154,22 @@ export function composeSlotModel(
|
|||||||
delete next[slot];
|
delete next[slot];
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
const model = provider && id ? { provider, id } : prev?.model;
|
||||||
next[slot] = {
|
next[slot] = {
|
||||||
...(provider && id ? { model: { provider, id } } : prev?.model ? { model: prev.model } : {}),
|
...(model ? { model } : {}),
|
||||||
...(thinking ? { thinkingLevel: thinking as ThinkingLevel } : {}),
|
...(thinking ? { thinkingLevel: thinking as ThinkingLevel } : {}),
|
||||||
|
...(model?.provider === "chatgpt" && prev?.fastMode !== undefined ? { fastMode: prev.fastMode } : {}),
|
||||||
};
|
};
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Speed belongs to the tier, independently of its reasoning budget. */
|
||||||
|
export function composeSlotFast(models: ModelsSettings | undefined, slot: ModelSlotName, enabled: boolean): ModelsSettings {
|
||||||
|
const current = models?.[slot];
|
||||||
|
if (current?.model?.provider !== "chatgpt") return { ...models };
|
||||||
|
return { ...models, [slot]: { ...current, fastMode: enabled } };
|
||||||
|
}
|
||||||
|
|
||||||
/** Compose: the search-engine preference. null = the explicit pin is
|
/** Compose: the search-engine preference. null = the explicit pin is
|
||||||
* REMOVED (auto-activation takes over — the schema's resting state).
|
* REMOVED (auto-activation takes over — the schema's resting state).
|
||||||
* Pure — exported for tests. */
|
* Pure — exported for tests. */
|
||||||
@@ -261,20 +263,22 @@ function MenuTrigger({
|
|||||||
/** The no-key search state grays the whole row; the trigger follows. */
|
/** The no-key search state grays the whole row; the trigger follows. */
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
|
disabled={disabled}
|
||||||
data-testid={testid}
|
data-testid={testid}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!disabled) onClick();
|
if (!disabled) onClick();
|
||||||
}}
|
}}
|
||||||
className={`flex h-7 items-center gap-1 rounded-sm border border-border bg-background px-1.5 font-ui text-xs text-foreground transition-colors ${
|
className={`flex h-7 max-w-full items-center gap-1 rounded-sm border border-border bg-background px-1.5 font-ui text-xs text-foreground transition-colors ${
|
||||||
disabled ? "cursor-not-allowed" : "hover:border-muted-foreground/50"
|
disabled ? "cursor-not-allowed" : "hover:border-muted-foreground/50"
|
||||||
} ${widthClass}`}
|
} ${widthClass}`}
|
||||||
>
|
>
|
||||||
<span className="min-w-0 flex-1 truncate">{value || emptyLabel}</span>
|
<span className="min-w-0 flex-1 truncate">{t(value || emptyLabel)}</span>
|
||||||
<span aria-hidden className="text-muted-foreground">
|
<span aria-hidden className="text-muted-foreground">
|
||||||
▾
|
▾
|
||||||
</span>
|
</span>
|
||||||
@@ -310,11 +314,21 @@ export function AgentSection({
|
|||||||
/** Writes the composed WHOLE agent section (replace-PUT discipline). */
|
/** Writes the composed WHOLE agent section (replace-PUT discipline). */
|
||||||
onSectionChange: (next: AgentSettings) => void;
|
onSectionChange: (next: AgentSettings) => void;
|
||||||
/** Writes the composed models section (slots — replace-PUT discipline). */
|
/** Writes the composed models section (slots — replace-PUT discipline). */
|
||||||
onModelsChange: (next: ModelsSettings) => void;
|
onModelsChange: (next: ModelsSettings) => void | Promise<void>;
|
||||||
/** Writes the whole compaction section (replace-PUT discipline). */
|
/** Writes the whole compaction section (replace-PUT discipline). */
|
||||||
onCompactionChange: (next: CompactionSettings) => void;
|
onCompactionChange: (next: CompactionSettings) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
const [catalog, setCatalog] = useState<ModelCatalogEntry[] | null>(null);
|
const [catalog, setCatalog] = useState<ModelCatalogEntry[] | null>(null);
|
||||||
|
const [savingModels, setSavingModels] = useState(false);
|
||||||
|
const [modelsError, setModelsError] = useState<string | null>(null);
|
||||||
|
const updateModels = async (next: ModelsSettings) => {
|
||||||
|
if (savingModels) return;
|
||||||
|
setSavingModels(true); setModelsError(null);
|
||||||
|
try { await onModelsChange(next); }
|
||||||
|
catch { setModelsError("Could not save model settings"); }
|
||||||
|
finally { setSavingModels(false); }
|
||||||
|
};
|
||||||
/** The three tiers' resolved pins — GET /api/models `slots`. The rows'
|
/** The three tiers' resolved pins — GET /api/models `slots`. The rows'
|
||||||
* Default hints and thinking lists resolve from it. */
|
* Default hints and thinking lists resolve from it. */
|
||||||
const [slots, setSlots] = useState<ModelsBody["slots"] | null>(null);
|
const [slots, setSlots] = useState<ModelsBody["slots"] | null>(null);
|
||||||
@@ -357,18 +371,23 @@ export function AgentSection({
|
|||||||
// fetch each per section mount. The models read ALSO re-runs on the
|
// fetch each per section mount. The models read ALSO re-runs on the
|
||||||
// cross-surface signal (fix wave: another surface's write re-reads
|
// cross-surface signal (fix wave: another surface's write re-reads
|
||||||
// into the slot rows live).
|
// into the slot rows live).
|
||||||
const readModels = useCallback(() => {
|
const modelsRequest = useRef(0);
|
||||||
|
const modelsMounted = useRef(false);
|
||||||
|
const readModels = useCallback((connectionChanged = false) => {
|
||||||
|
const request = ++modelsRequest.current;
|
||||||
|
if (connectionChanged) setCatalog(null);
|
||||||
fetch("/api/models")
|
fetch("/api/models")
|
||||||
.then((r) => r.json())
|
.then((r) => { if (!r.ok) throw new Error("Catalog unavailable"); return r.json(); })
|
||||||
.then((body: ModelsBody) => {
|
.then((body: ModelsBody) => {
|
||||||
|
if (!modelsMounted.current || request !== modelsRequest.current) return;
|
||||||
setCatalog(body.models);
|
setCatalog(body.models);
|
||||||
setSlots(body.slots);
|
setSlots(body.slots);
|
||||||
})
|
})
|
||||||
.catch(() => setCatalog(null));
|
.catch(() => { if (modelsMounted.current && request === modelsRequest.current) setCatalog(null); });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useSettingsSaved((section) => {
|
useSettingsSaved((section) => {
|
||||||
if (section === "models") readModels();
|
if (section === "models" || section === "keys") readModels(section === "keys");
|
||||||
// THE state-echo root cause: the prompt switches write through the
|
// THE state-echo root cause: the prompt switches write through the
|
||||||
// agent section, but this view (the toggles' truth) never re-read —
|
// agent section, but this view (the toggles' truth) never re-read —
|
||||||
// the settings-saved law (every reader re-reads) was missing this
|
// the settings-saved law (every reader re-reads) was missing this
|
||||||
@@ -377,6 +396,7 @@ export function AgentSection({
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
modelsMounted.current = true;
|
||||||
readModels();
|
readModels();
|
||||||
fetch("/api/settings/prompts")
|
fetch("/api/settings/prompts")
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
@@ -393,6 +413,7 @@ export function AgentSection({
|
|||||||
setEngineKeys(present);
|
setEngineKeys(present);
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
return () => { modelsMounted.current = false; modelsRequest.current++; };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// The reason line re-reads /api/search AFTER an engine write lands —
|
// The reason line re-reads /api/search AFTER an engine write lands —
|
||||||
@@ -496,7 +517,7 @@ export function AgentSection({
|
|||||||
>
|
>
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{live === null ? (active && <span aria-hidden>✓</span>) : live ? <span aria-hidden>✓ key</span> : <span aria-hidden>· no key</span>}
|
{live === null ? (active && <span aria-hidden>✓</span>) : live ? <span aria-hidden>{t("✓ key")}</span> : <span aria-hidden>{t("· no key")}</span>}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -531,7 +552,7 @@ export function AgentSection({
|
|||||||
<Toggle
|
<Toggle
|
||||||
checked={info.enabled}
|
checked={info.enabled}
|
||||||
disabled={!fileExists}
|
disabled={!fileExists}
|
||||||
label={`prompt ${info.path}`}
|
label={t("prompt {v0}", { v0: info.path })}
|
||||||
onChange={(on) => onSectionChange(composePromptSwitch(agent, info.path, on))}
|
onChange={(on) => onSectionChange(composePromptSwitch(agent, info.path, on))}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
@@ -544,26 +565,26 @@ export function AgentSection({
|
|||||||
}}
|
}}
|
||||||
className="text-xs text-muted-foreground/70 underline-offset-2 hover:text-foreground hover:underline"
|
className="text-xs text-muted-foreground/70 underline-offset-2 hover:text-foreground hover:underline"
|
||||||
>
|
>
|
||||||
{open ? "Hide text" : "Show text"}
|
{open ? t("Hide text") : t("Show text")}
|
||||||
</button>
|
</button>
|
||||||
{open && (
|
{open && (
|
||||||
<div className="mt-1 flex flex-col gap-2">
|
<div className="mt-1 flex flex-col gap-2">
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-1 text-xs uppercase tracking-widest text-muted-foreground/70">Source</p>
|
<p className="mb-1 text-xs uppercase tracking-widest text-muted-foreground/70">{t("Source")}</p>
|
||||||
<textarea
|
<textarea
|
||||||
readOnly
|
readOnly
|
||||||
value={info.sourceText}
|
value={info.sourceText}
|
||||||
aria-label="The original instructions"
|
aria-label={t("The original instructions")}
|
||||||
className="h-24 w-full resize-y rounded-sm border border-border bg-muted p-2 font-ui text-xs text-muted-foreground"
|
className="h-24 w-full resize-y rounded-sm border border-border bg-muted p-2 font-ui text-xs text-muted-foreground"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-1 text-xs uppercase tracking-widest text-muted-foreground/70">Override</p>
|
<p className="mb-1 text-xs uppercase tracking-widest text-muted-foreground/70">{t("Override")}</p>
|
||||||
<textarea
|
<textarea
|
||||||
value={segmentDraft[info.path] ?? info.overrideText ?? ""}
|
value={segmentDraft[info.path] ?? info.overrideText ?? ""}
|
||||||
onChange={(e) => setSegmentDraft({ ...segmentDraft, [info.path]: e.target.value })}
|
onChange={(e) => setSegmentDraft({ ...segmentDraft, [info.path]: e.target.value })}
|
||||||
aria-label="Your version of the instructions"
|
aria-label={t("Your version of the instructions")}
|
||||||
placeholder="Add your own instructions…"
|
placeholder={t("Add your own instructions…")}
|
||||||
className="h-24 w-full resize-y rounded-sm border border-border bg-background p-2 font-ui text-xs text-foreground outline-none"
|
className="h-24 w-full resize-y rounded-sm border border-border bg-background p-2 font-ui text-xs text-foreground outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -578,16 +599,14 @@ export function AgentSection({
|
|||||||
: "cursor-not-allowed text-xs text-muted-foreground/40 underline-offset-2"
|
: "cursor-not-allowed text-xs text-muted-foreground/40 underline-offset-2"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Save
|
{t("Save")}</button>
|
||||||
</button>
|
|
||||||
{fileExists && (
|
{fileExists && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void deleteSegment(info.path)}
|
onClick={() => void deleteSegment(info.path)}
|
||||||
className="text-xs text-muted-foreground/70 underline-offset-2 hover:text-red-500 hover:underline"
|
className="text-xs text-muted-foreground/70 underline-offset-2 hover:text-red-500 hover:underline"
|
||||||
>
|
>
|
||||||
Delete
|
{t("Delete")}</button>
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
{/* The quiet inline confirmation — next to the
|
{/* The quiet inline confirmation — next to the
|
||||||
action, not the old path-echoing line at the
|
action, not the old path-echoing line at the
|
||||||
@@ -597,7 +616,7 @@ export function AgentSection({
|
|||||||
status reads unambiguously. */}
|
status reads unambiguously. */}
|
||||||
{segmentStatus && (
|
{segmentStatus && (
|
||||||
<p className="text-xs text-muted-foreground/70" data-testid="segment-status">
|
<p className="text-xs text-muted-foreground/70" data-testid="segment-status">
|
||||||
{segmentStatus}
|
{t(segmentStatus)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -618,7 +637,8 @@ export function AgentSection({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SettingsGroup label="Models" testid="group-models">
|
<SettingsGroup label={t("Models")} testid="group-models">
|
||||||
|
{modelsError && <p role="alert" className="text-xs text-destructive">{t(modelsError)}</p>}
|
||||||
{MODEL_SLOTS.map((slot) => {
|
{MODEL_SLOTS.map((slot) => {
|
||||||
const override = models?.[slot];
|
const override = models?.[slot];
|
||||||
const modelId = override?.model?.id ?? "";
|
const modelId = override?.model?.id ?? "";
|
||||||
@@ -646,34 +666,51 @@ export function AgentSection({
|
|||||||
label={SLOT_META[slot].label}
|
label={SLOT_META[slot].label}
|
||||||
hint={SLOT_META[slot].roster}
|
hint={SLOT_META[slot].roster}
|
||||||
testid={`slot-row-${slot}`}
|
testid={`slot-row-${slot}`}
|
||||||
|
className="flex-wrap items-start gap-y-2 [&>span:first-child]:flex-[1_1_12rem]"
|
||||||
>
|
>
|
||||||
{/* The anchor for the outside-click contract is the
|
{/* The anchor for the outside-click contract is the
|
||||||
span holding BOTH triggers of the open menu. */}
|
span holding the tier's controls. */}
|
||||||
<span
|
<span
|
||||||
ref={thisMenuOpen ? menuAnchorRef : undefined}
|
ref={thisMenuOpen ? menuAnchorRef : undefined}
|
||||||
className="relative flex items-center gap-2"
|
className="relative flex w-68 max-w-full shrink-0 flex-col items-start gap-2"
|
||||||
>
|
>
|
||||||
<MenuTrigger
|
<span data-testid={`slot-controls-${slot}`} className="grid w-full grid-cols-[minmax(0,1fr)_5rem_auto] items-center gap-2">
|
||||||
value={modelId ? prettyModel(modelId) : ""}
|
<MenuTrigger
|
||||||
emptyLabel="—"
|
value={modelId ? prettyModel(modelId) : ""}
|
||||||
ariaLabel={`${SLOT_META[slot].label} model`}
|
emptyLabel="—"
|
||||||
testid={`slot-model-${slot}`}
|
ariaLabel={t("{v0} model", { v0: t(SLOT_META[slot].label) })}
|
||||||
open={thisMenuOpen === "model"}
|
testid={`slot-model-${slot}`}
|
||||||
onClick={() => setOpenMenu(thisMenuOpen === "model" ? null : { target: slot, kind: "model" })}
|
open={thisMenuOpen === "model"}
|
||||||
widthClass="min-w-20"
|
onClick={() => setOpenMenu(thisMenuOpen === "model" ? null : { target: slot, kind: "model" })}
|
||||||
/>
|
widthClass="w-full min-w-0"
|
||||||
<MenuTrigger
|
disabled={savingModels}
|
||||||
// Legacy display (chunk 2 item 4): no stored level + a known
|
/>
|
||||||
// model → the RESOLVED level (the ordering's first supported —
|
<MenuTrigger
|
||||||
// what the engine build runs). Display-only until a pick writes.
|
// Legacy display (chunk 2 item 4): no stored level + a known
|
||||||
value={thinking || (chosen ? defaultOrderLevel(levels) : "")}
|
// model → the RESOLVED level (the ordering's first supported —
|
||||||
emptyLabel="—"
|
// what the engine build runs). Display-only until a pick writes.
|
||||||
ariaLabel={`${SLOT_META[slot].label} thinking`}
|
value={thinking || (chosen ? defaultOrderLevel(levels) : "")}
|
||||||
testid={`slot-thinking-${slot}`}
|
emptyLabel="—"
|
||||||
open={thisMenuOpen === "thinking"}
|
ariaLabel={t("{v0} thinking", { v0: t(SLOT_META[slot].label) })}
|
||||||
onClick={() => setOpenMenu(thisMenuOpen === "thinking" ? null : { target: slot, kind: "thinking" })}
|
testid={`slot-thinking-${slot}`}
|
||||||
widthClass="w-20"
|
open={thisMenuOpen === "thinking"}
|
||||||
/>
|
onClick={() => setOpenMenu(thisMenuOpen === "thinking" ? null : { target: slot, kind: "thinking" })}
|
||||||
|
widthClass="w-20"
|
||||||
|
disabled={savingModels}
|
||||||
|
/>
|
||||||
|
<Hint label={t(provider === "chatgpt" ? "Fast mode uses more ChatGPT allowance." : "Fast mode is available only for ChatGPT models.")}>
|
||||||
|
<span className="inline-flex">
|
||||||
|
<button type="button" data-testid={`slot-fast-${slot}`} aria-label={t("{v0} fast mode", { v0: t(SLOT_META[slot].label) })}
|
||||||
|
aria-pressed={provider === "chatgpt" && override?.fastMode === true} disabled={provider !== "chatgpt" || !chosen || savingModels}
|
||||||
|
onClick={() => { setOpenMenu(null); void updateModels(composeSlotFast(models, slot, !override?.fastMode)); }}
|
||||||
|
className={cn("flex h-7 items-center gap-1 rounded-sm border border-border px-1.5 font-ui text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-40",
|
||||||
|
provider === "chatgpt" && override?.fastMode ? "bg-muted text-foreground" : "bg-background text-muted-foreground hover:border-muted-foreground/50")}>
|
||||||
|
<ZapIcon className="size-3.5" aria-hidden /><span>{t("Fast")}</span>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</Hint>
|
||||||
|
</span>
|
||||||
|
{slot !== "strong" && <><ChatGPTBackgroundAccess provider={coord?.provider} /><NativeSubscriptionBackgroundAccess provider={coord?.provider} /></>}
|
||||||
{thisMenuOpen === "model" && (
|
{thisMenuOpen === "model" && (
|
||||||
<MenuShell
|
<MenuShell
|
||||||
preferredSide="down"
|
preferredSide="down"
|
||||||
@@ -690,13 +727,13 @@ export function AgentSection({
|
|||||||
// fresh pick writes a level — the stored one rides as-is
|
// fresh pick writes a level — the stored one rides as-is
|
||||||
// (the write clamps server-side), else the ordering's level.
|
// (the write clamps server-side), else the ordering's level.
|
||||||
const next = populateLevel(thinking || undefined, entry.supportedLevels);
|
const next = populateLevel(thinking || undefined, entry.supportedLevels);
|
||||||
onModelsChange(composeSlotModel(models, slot, entry.provider, entry.id, next));
|
void updateModels(composeSlotModel(models, slot, entry.provider, entry.id, next));
|
||||||
setOpenMenu(null);
|
setOpenMenu(null);
|
||||||
}}
|
}}
|
||||||
footer={
|
footer={
|
||||||
catalog && catalog.length === 0
|
catalog && catalog.length === 0
|
||||||
? "No models — add a provider key in Keys"
|
? t("No models — add a provider key in Keys")
|
||||||
: "Runs every agent of this tier"
|
: t("Runs every agent of this tier")
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</MenuShell>
|
</MenuShell>
|
||||||
@@ -714,7 +751,7 @@ export function AgentSection({
|
|||||||
// the same one the trigger shows.
|
// the same one the trigger shows.
|
||||||
effectiveLevel={thinking || (chosen ? defaultOrderLevel(levels) : undefined)}
|
effectiveLevel={thinking || (chosen ? defaultOrderLevel(levels) : undefined)}
|
||||||
onPick={(level) => {
|
onPick={(level) => {
|
||||||
onModelsChange(composeSlotModel(models, slot, provider, modelId, level));
|
void updateModels(composeSlotModel(models, slot, provider, modelId, level));
|
||||||
setOpenMenu(null);
|
setOpenMenu(null);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -726,7 +763,7 @@ export function AgentSection({
|
|||||||
})}
|
})}
|
||||||
</SettingsGroup>
|
</SettingsGroup>
|
||||||
|
|
||||||
<SettingsGroup label="Tools" testid="group-tools">
|
<SettingsGroup label={t("Tools")} testid="group-tools">
|
||||||
{/* The prober's graph tools (wave 7, owner ruling 2026-09-23):
|
{/* The prober's graph tools (wave 7, owner ruling 2026-09-23):
|
||||||
FIRST row of the group — its subject is the map, its
|
FIRST row of the group — its subject is the map, its
|
||||||
mechanism is agent capability, so it lives in Tools but
|
mechanism is agent capability, so it lives in Tools but
|
||||||
@@ -738,15 +775,15 @@ export function AgentSection({
|
|||||||
const graphOn = graph?.enabled ?? true;
|
const graphOn = graph?.enabled ?? true;
|
||||||
return (
|
return (
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
label="Graph tools"
|
label={t("Graph tools")}
|
||||||
hint={graphOn ? "Lets the tutor consult your knowledge map between sessions" : "Graph is disabled"}
|
hint={graphOn ? t("Lets the tutor consult your knowledge map between sessions") : t("Graph is disabled")}
|
||||||
testid="graph-tools-row"
|
testid="graph-tools-row"
|
||||||
dimmed={!graphOn}
|
dimmed={!graphOn}
|
||||||
>
|
>
|
||||||
<Toggle
|
<Toggle
|
||||||
checked={agent?.graphTools?.enabled ?? true}
|
checked={agent?.graphTools?.enabled ?? true}
|
||||||
disabled={!graphOn}
|
disabled={!graphOn}
|
||||||
label="graph tools"
|
label={t("graph tools")}
|
||||||
onChange={(on) => onSectionChange(composeGraphTools(agent, on))}
|
onChange={(on) => onSectionChange(composeGraphTools(agent, on))}
|
||||||
/>
|
/>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
@@ -760,14 +797,15 @@ export function AgentSection({
|
|||||||
const free = agent?.tools?.explain === true;
|
const free = agent?.tools?.explain === true;
|
||||||
return (
|
return (
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
label="Quizzing"
|
label={t("Quizzing")}
|
||||||
hint="How both tutors check your understanding"
|
hint={t("How both tutors check your understanding")}
|
||||||
testid="quiz-mechanics-row"
|
testid="quiz-mechanics-row"
|
||||||
|
className="flex-wrap gap-y-2 [&>span]:flex-[1_1_12rem]"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
role="radiogroup"
|
role="radiogroup"
|
||||||
aria-label="Quizzing mechanics"
|
aria-label={t("Quizzing mechanics")}
|
||||||
className="flex rounded-lg border border-border/60 bg-muted/30 p-0.5"
|
className="flex max-w-full shrink-0 rounded-lg border border-border/60 bg-muted/30 p-0.5"
|
||||||
>
|
>
|
||||||
{(["choice", "free"] as const).map((m) => {
|
{(["choice", "free"] as const).map((m) => {
|
||||||
const active = m === "free" ? free : !free;
|
const active = m === "free" ? free : !free;
|
||||||
@@ -780,11 +818,11 @@ export function AgentSection({
|
|||||||
data-testid={`quiz-mechanics-${m}`}
|
data-testid={`quiz-mechanics-${m}`}
|
||||||
onClick={() => onSectionChange(composeQuizMechanics(agent, m))}
|
onClick={() => onSectionChange(composeQuizMechanics(agent, m))}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-8 rounded-md px-3 text-sm font-medium transition-colors",
|
"min-h-8 min-w-0 rounded-md px-3 py-1 text-sm leading-snug font-medium break-words transition-colors",
|
||||||
active ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground",
|
active ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{m === "choice" ? "Multiple choice" : "Free recall"}
|
{m === "choice" ? t("Multiple choice") : t("Free recall")}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -792,35 +830,26 @@ export function AgentSection({
|
|||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
{TOGGLE_TOOLS.map((id) => {
|
{TOGGLE_TOOLS.map((id) => (
|
||||||
// Research needs a live search engine; the row + toggle gray
|
<SettingsRow
|
||||||
// on the same signal the registry drops the tool on (see
|
key={id}
|
||||||
// researchToolUnavailable). The reason rides as the hint.
|
label={t("Tool · {v0}", { v0: id })}
|
||||||
const searchDown = researchToolUnavailable(id, search);
|
testid={`tool-row-${id}`}
|
||||||
return (
|
>
|
||||||
<SettingsRow
|
<Toggle
|
||||||
key={id}
|
checked={agent?.tools?.[id] ?? true}
|
||||||
label={`Tool · ${id}`}
|
label={t("tool {v0}", { v0: id })}
|
||||||
hint={searchDown ? search?.reason : undefined}
|
onChange={(on) => onSectionChange(composeTool(agent, id, on))}
|
||||||
testid={`tool-row-${id}`}
|
/>
|
||||||
dimmed={searchDown}
|
</SettingsRow>
|
||||||
>
|
))}
|
||||||
<Toggle
|
|
||||||
checked={agent?.tools?.[id] ?? true}
|
|
||||||
disabled={searchDown}
|
|
||||||
label={`tool ${id}`}
|
|
||||||
onChange={(on) => onSectionChange(composeTool(agent, id, on))}
|
|
||||||
/>
|
|
||||||
</SettingsRow>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{(() => {
|
{(() => {
|
||||||
const engine = agent?.search?.engine ?? null;
|
const engine = agent?.search?.engine ?? null;
|
||||||
const anyKey = engineKeys.tavily === true || engineKeys.brave === true;
|
const anyKey = engineKeys.tavily === true || engineKeys.brave === true;
|
||||||
const open = openMenu?.target === "search";
|
const open = openMenu?.target === "search";
|
||||||
return (
|
return (
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
label="Search engine"
|
label={t("Search engine")}
|
||||||
hint={search?.reason ?? undefined}
|
hint={search?.reason ?? undefined}
|
||||||
testid="search-engine-row"
|
testid="search-engine-row"
|
||||||
dimmed={!anyKey}
|
dimmed={!anyKey}
|
||||||
@@ -828,8 +857,8 @@ export function AgentSection({
|
|||||||
<span ref={open ? menuAnchorRef : undefined} className="relative flex items-center gap-2">
|
<span ref={open ? menuAnchorRef : undefined} className="relative flex items-center gap-2">
|
||||||
<MenuTrigger
|
<MenuTrigger
|
||||||
value={engine === "tavily" ? "Tavily" : engine === "brave" ? "Brave" : ""}
|
value={engine === "tavily" ? "Tavily" : engine === "brave" ? "Brave" : ""}
|
||||||
emptyLabel="Automatic"
|
emptyLabel={t("Automatic")}
|
||||||
ariaLabel="Search engine"
|
ariaLabel={t("Search engine")}
|
||||||
testid="search-engine-trigger"
|
testid="search-engine-trigger"
|
||||||
open={open}
|
open={open}
|
||||||
onClick={() => setOpenMenu(open ? null : { target: "search", kind: "engine" })}
|
onClick={() => setOpenMenu(open ? null : { target: "search", kind: "engine" })}
|
||||||
@@ -843,7 +872,7 @@ export function AgentSection({
|
|||||||
testid="search-engine-menu"
|
testid="search-engine-menu"
|
||||||
menuClass="flex w-44 flex-col gap-0.5 rounded-xl border border-border bg-card p-2 text-xs shadow-xl"
|
menuClass="flex w-44 flex-col gap-0.5 rounded-xl border border-border bg-card p-2 text-xs shadow-xl"
|
||||||
>
|
>
|
||||||
{engineOption("auto", "Automatic", engine === null, null, () => {
|
{engineOption("auto", t("Automatic"), engine === null, null, () => {
|
||||||
onSectionChange(composeSearchEngine(agent, null));
|
onSectionChange(composeSearchEngine(agent, null));
|
||||||
setOpenMenu(null);
|
setOpenMenu(null);
|
||||||
})}
|
})}
|
||||||
@@ -861,17 +890,17 @@ export function AgentSection({
|
|||||||
})()}
|
})()}
|
||||||
</SettingsGroup>
|
</SettingsGroup>
|
||||||
|
|
||||||
<SettingsGroup label="Context" testid="group-context">
|
<SettingsGroup label={t("Context")} testid="group-context">
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
label="Compaction threshold"
|
label={t("Compaction threshold")}
|
||||||
hint={`${resolvedCompaction.thresholdTokens} tok`}
|
hint={t("{v0} tok", { v0: resolvedCompaction.thresholdTokens })}
|
||||||
testid="compaction-row-threshold"
|
testid="compaction-row-threshold"
|
||||||
>
|
>
|
||||||
<CommitInput
|
<CommitInput
|
||||||
type="number"
|
type="number"
|
||||||
value={String(compaction?.thresholdTokens ?? "")}
|
value={String(compaction?.thresholdTokens ?? "")}
|
||||||
placeholder={String(resolvedCompaction.thresholdTokens)}
|
placeholder={String(resolvedCompaction.thresholdTokens)}
|
||||||
ariaLabel="compaction threshold"
|
ariaLabel={t("compaction threshold")}
|
||||||
testid="input-compaction-threshold"
|
testid="input-compaction-threshold"
|
||||||
onCommit={(v) => {
|
onCommit={(v) => {
|
||||||
// Empty = factory default (the budget row's discipline):
|
// Empty = factory default (the budget row's discipline):
|
||||||
@@ -894,9 +923,9 @@ export function AgentSection({
|
|||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
</SettingsGroup>
|
</SettingsGroup>
|
||||||
|
|
||||||
<SettingsGroup label="System prompts" testid="group-prompts">
|
<SettingsGroup label={t("System prompts")} testid="group-prompts">
|
||||||
{prompts === null ? (
|
{prompts === null ? (
|
||||||
<p className="text-xs text-muted-foreground/70">Loading prompt segments…</p>
|
<p className="text-xs text-muted-foreground/70">{t("Loading prompt segments…")}</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{/* The owner's ruled order (2026-09-26): Teacher first,
|
{/* The owner's ruled order (2026-09-26): Teacher first,
|
||||||
@@ -906,10 +935,10 @@ export function AgentSection({
|
|||||||
{sectionedSegments(prompts).map((section) => (
|
{sectionedSegments(prompts).map((section) => (
|
||||||
<div key={section.id} data-testid={`prompt-section-${section.id}`}>
|
<div key={section.id} data-testid={`prompt-section-${section.id}`}>
|
||||||
<p className="mb-1 text-xs uppercase tracking-widest text-muted-foreground/70">
|
<p className="mb-1 text-xs uppercase tracking-widest text-muted-foreground/70">
|
||||||
{section.label}
|
{t(section.label)}
|
||||||
</p>
|
</p>
|
||||||
{section.hint && (
|
{section.hint && (
|
||||||
<p className="mb-1 text-xs text-muted-foreground/60">{section.hint}</p>
|
<p className="mb-1 text-xs text-muted-foreground/60">{t(section.hint)}</p>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{section.segments.map((info) => segmentRow(info))}
|
{section.segments.map((info) => segmentRow(info))}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { useI18n } from "@/components/i18n";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The Appearance + Graph sections (thin settings wave).
|
* The Appearance + Graph sections (thin settings wave).
|
||||||
*
|
*
|
||||||
@@ -97,6 +99,7 @@ export function AppearanceSection({
|
|||||||
/** PUT the whole appearance section; the page re-applies on success. */
|
/** PUT the whole appearance section; the page re-applies on success. */
|
||||||
onSectionChange: (next: AppearanceSettings) => void;
|
onSectionChange: (next: AppearanceSettings) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t, language } = useI18n();
|
||||||
const { appearance: resolved } = useAppearance();
|
const { appearance: resolved } = useAppearance();
|
||||||
const md = appearance?.mdRendering;
|
const md = appearance?.mdRendering;
|
||||||
|
|
||||||
@@ -186,27 +189,27 @@ export function AppearanceSection({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SettingsRow label="Mode" hint="Coming soon" testid="row-mode" dimmed>
|
<SettingsRow label={t("Mode")} hint={t("Coming soon")} testid="row-mode" dimmed>
|
||||||
<ThinSelect value="dark" ariaLabel="mode" onChange={() => {}} testid="select-mode" disabled>
|
<ThinSelect value="dark" ariaLabel="mode" onChange={() => {}} testid="select-mode" disabled>
|
||||||
<option value="dark">Dark</option>
|
<option value="dark">{t("Dark")}</option>
|
||||||
</ThinSelect>
|
</ThinSelect>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
label="Background"
|
label={t("Background")}
|
||||||
hint={`Empty = ${resolved.background ?? "#141414"}`}
|
hint={t("Empty = {v0}", { v0: resolved.background ?? "#141414" })}
|
||||||
testid="row-background"
|
testid="row-background"
|
||||||
>
|
>
|
||||||
<CommitInput
|
<CommitInput
|
||||||
value={appearance?.background ?? ""}
|
value={appearance?.background ?? ""}
|
||||||
placeholder="#141414"
|
placeholder="#141414"
|
||||||
ariaLabel="background hex"
|
ariaLabel={t("background hex")}
|
||||||
testid="input-background"
|
testid="input-background"
|
||||||
onCommit={(v) => onSectionChange({ ...appearance, background: v === "" ? null : v })}
|
onCommit={(v) => onSectionChange({ ...appearance, background: v === "" ? null : v })}
|
||||||
/>
|
/>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
label="Reading font"
|
label={t("Reading font")}
|
||||||
hint={`Empty = ${DEFAULT_READING_FONT}`}
|
hint={t("Empty = {v0}", { v0: language === "ru" ? "Noto Sans" : DEFAULT_READING_FONT })}
|
||||||
testid="row-font"
|
testid="row-font"
|
||||||
>
|
>
|
||||||
<span ref={fontMenuOpen ? menuAnchorRef : undefined} className="relative flex items-center gap-2">
|
<span ref={fontMenuOpen ? menuAnchorRef : undefined} className="relative flex items-center gap-2">
|
||||||
@@ -222,13 +225,13 @@ export function AppearanceSection({
|
|||||||
const triggerButton = (
|
const triggerButton = (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Reading font"
|
aria-label={t("Reading font")}
|
||||||
aria-expanded={fontMenuOpen}
|
aria-expanded={fontMenuOpen}
|
||||||
data-testid="font-trigger"
|
data-testid="font-trigger"
|
||||||
onClick={openFontMenu}
|
onClick={openFontMenu}
|
||||||
className="flex h-7 min-w-20 items-center gap-1 rounded-sm border border-border bg-background px-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50"
|
className="flex h-7 min-w-20 items-center gap-1 rounded-sm border border-border bg-background px-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50"
|
||||||
>
|
>
|
||||||
<span className="min-w-0 flex-1 truncate">{appearance?.fontFamily || DEFAULT_READING_FONT}</span>
|
<span className="min-w-0 flex-1 truncate">{appearance?.fontFamily || (language === "ru" ? "Noto Sans" : DEFAULT_READING_FONT)}</span>
|
||||||
<span aria-hidden className="text-muted-foreground">
|
<span aria-hidden className="text-muted-foreground">
|
||||||
▾
|
▾
|
||||||
</span>
|
</span>
|
||||||
@@ -237,7 +240,7 @@ export function AppearanceSection({
|
|||||||
return fontMenuOpen ? (
|
return fontMenuOpen ? (
|
||||||
triggerButton
|
triggerButton
|
||||||
) : (
|
) : (
|
||||||
<Hint label="Choose from your installed fonts" side="left">
|
<Hint label={t("Choose from your installed fonts")} side="left">
|
||||||
{triggerButton}
|
{triggerButton}
|
||||||
</Hint>
|
</Hint>
|
||||||
);
|
);
|
||||||
@@ -258,21 +261,21 @@ export function AppearanceSection({
|
|||||||
? undefined
|
? undefined
|
||||||
: localFonts === null
|
: localFonts === null
|
||||||
? enumEmpty
|
? enumEmpty
|
||||||
? "Common families — installed fonts could not be read"
|
? t("Common families — installed fonts could not be read")
|
||||||
: "Common families — your installed list needs the desktop app"
|
: t("Common families — your installed list needs the desktop app")
|
||||||
: "Your installed fonts"
|
: t("Your installed fonts")
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</MenuShell>
|
</MenuShell>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
<SettingsRow label="Reading font size" hint={`${resolved.proseFontSize}px`} testid="row-prose-size">
|
<SettingsRow label={t("Reading font size")} hint={`${resolved.proseFontSize}px`} testid="row-prose-size">
|
||||||
<CommitInput
|
<CommitInput
|
||||||
type="number"
|
type="number"
|
||||||
value={String(appearance?.proseFontSize ?? "")}
|
value={String(appearance?.proseFontSize ?? "")}
|
||||||
placeholder="16"
|
placeholder="16"
|
||||||
ariaLabel="prose font size"
|
ariaLabel={t("prose font size")}
|
||||||
testid="input-prose-size"
|
testid="input-prose-size"
|
||||||
onCommit={(v) => {
|
onCommit={(v) => {
|
||||||
// Empty = factory default (owner, fix wave): omit the key so the record
|
// Empty = factory default (owner, fix wave): omit the key so the record
|
||||||
@@ -288,19 +291,19 @@ export function AppearanceSection({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
<SettingsRow label="Session names in serif" testid="row-session-serif">
|
<SettingsRow label={t("Session names in serif")} testid="row-session-serif">
|
||||||
<Toggle
|
<Toggle
|
||||||
checked={appearance?.sessionNameSerif ?? true}
|
checked={appearance?.sessionNameSerif ?? true}
|
||||||
label="session names serif"
|
label={t("session names serif")}
|
||||||
onChange={(on) => onSectionChange({ ...appearance, sessionNameSerif: on })}
|
onChange={(on) => onSectionChange({ ...appearance, sessionNameSerif: on })}
|
||||||
/>
|
/>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
<SettingsRow label="Heading weight" hint={`${resolved.mdRendering.headingWeight}`} testid="row-heading-weight">
|
<SettingsRow label={t("Heading weight")} hint={`${resolved.mdRendering.headingWeight}`} testid="row-heading-weight">
|
||||||
<CommitInput
|
<CommitInput
|
||||||
type="number"
|
type="number"
|
||||||
value={String(md?.headingWeight ?? "")}
|
value={String(md?.headingWeight ?? "")}
|
||||||
placeholder="900"
|
placeholder="900"
|
||||||
ariaLabel="heading weight"
|
ariaLabel={t("heading weight")}
|
||||||
testid="input-heading-weight"
|
testid="input-heading-weight"
|
||||||
onCommit={(v) => {
|
onCommit={(v) => {
|
||||||
// Empty = factory default (owner, fix wave): Number("") is 0, so
|
// Empty = factory default (owner, fix wave): Number("") is 0, so
|
||||||
@@ -321,20 +324,20 @@ export function AppearanceSection({
|
|||||||
{/* The emphasis inks default to the REGULAR TEXT COLOR now
|
{/* The emphasis inks default to the REGULAR TEXT COLOR now
|
||||||
(appearance wave, 2026-09-26) — the empty-state labels say
|
(appearance wave, 2026-09-26) — the empty-state labels say
|
||||||
so; explicit hex overrides ride unchanged. */}
|
so; explicit hex overrides ride unchanged. */}
|
||||||
<SettingsRow label="Bold color" hint="Empty = same as text" testid="row-bold-color">
|
<SettingsRow label={t("Bold color")} hint={t("Empty = same as text")} testid="row-bold-color">
|
||||||
<CommitInput
|
<CommitInput
|
||||||
value={md?.boldColor ?? ""}
|
value={md?.boldColor ?? ""}
|
||||||
placeholder="same as text"
|
placeholder={t("same as text")}
|
||||||
ariaLabel="bold color"
|
ariaLabel={t("bold color")}
|
||||||
testid="input-bold-color"
|
testid="input-bold-color"
|
||||||
onCommit={(v) => onSectionChange({ ...appearance, mdRendering: { ...md, boldColor: v === "" ? null : v } })}
|
onCommit={(v) => onSectionChange({ ...appearance, mdRendering: { ...md, boldColor: v === "" ? null : v } })}
|
||||||
/>
|
/>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
<SettingsRow label="Italic color" hint="Empty = same as text" testid="row-italic-color">
|
<SettingsRow label={t("Italic color")} hint={t("Empty = same as text")} testid="row-italic-color">
|
||||||
<CommitInput
|
<CommitInput
|
||||||
value={md?.italicColor ?? ""}
|
value={md?.italicColor ?? ""}
|
||||||
placeholder="same as text"
|
placeholder={t("same as text")}
|
||||||
ariaLabel="italic color"
|
ariaLabel={t("italic color")}
|
||||||
testid="input-italic-color"
|
testid="input-italic-color"
|
||||||
onCommit={(v) => onSectionChange({ ...appearance, mdRendering: { ...md, italicColor: v === "" ? null : v } })}
|
onCommit={(v) => onSectionChange({ ...appearance, mdRendering: { ...md, italicColor: v === "" ? null : v } })}
|
||||||
/>
|
/>
|
||||||
@@ -350,6 +353,7 @@ export function GraphSection({
|
|||||||
graph: GraphSettings | undefined;
|
graph: GraphSettings | undefined;
|
||||||
onSectionChange: (next: GraphSettings) => void;
|
onSectionChange: (next: GraphSettings) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
// Wave 7 collapse (owner ruling 2026-09-23): the section is the MASTER
|
// Wave 7 collapse (owner ruling 2026-09-23): the section is the MASTER
|
||||||
// SWITCH — the embedding/clustering/labeling rungs are gone; clustering
|
// SWITCH — the embedding/clustering/labeling rungs are gone; clustering
|
||||||
// is the free birth-session partition derived on read, labels ARE session
|
// is the free birth-session partition derived on read, labels ARE session
|
||||||
@@ -370,10 +374,10 @@ export function GraphSection({
|
|||||||
switch below arrived: off ≠ no graph, off = the graph stops
|
switch below arrived: off ≠ no graph, off = the graph stops
|
||||||
growing. The testid keeps the old name's shape (row-enabled) so
|
growing. The testid keeps the old name's shape (row-enabled) so
|
||||||
this stays one rename, not a test re-pin. */}
|
this stays one rename, not a test re-pin. */}
|
||||||
<SettingsRow label="Extraction" hint="Off = the graph stops growing" testid="graph-row-enabled">
|
<SettingsRow label={t("Extraction")} hint={t("Off = the graph stops growing")} testid="graph-row-enabled">
|
||||||
<Toggle
|
<Toggle
|
||||||
checked={graph?.enabled ?? true}
|
checked={graph?.enabled ?? true}
|
||||||
label="extraction enabled"
|
label={t("extraction enabled")}
|
||||||
onChange={(on) =>
|
onChange={(on) =>
|
||||||
// Compose, never replace-whole (the section PUT is, but the
|
// Compose, never replace-whole (the section PUT is, but the
|
||||||
// row must not drop its sibling field — fixed with the budget
|
// row must not drop its sibling field — fixed with the budget
|
||||||
@@ -386,23 +390,23 @@ export function GraphSection({
|
|||||||
round): display-only. Off = the home shows the chains view
|
round): display-only. Off = the home shows the chains view
|
||||||
instead of the canvas. The ledgers, the pipeline, everything
|
instead of the canvas. The ledgers, the pipeline, everything
|
||||||
data keeps working — that is the extraction switch above. */}
|
data keeps working — that is the extraction switch above. */}
|
||||||
<SettingsRow label="Graph rendering" hint="Off = home shows the chains view" testid="graph-row-rendering">
|
<SettingsRow label={t("Graph rendering")} hint={t("Off = home shows the chains view")} testid="graph-row-rendering">
|
||||||
<Toggle
|
<Toggle
|
||||||
checked={graph?.rendering ?? true}
|
checked={graph?.rendering ?? true}
|
||||||
label="graph rendering enabled"
|
label={t("graph rendering enabled")}
|
||||||
onChange={(on) => onSectionChange({ ...graph, rendering: on })}
|
onChange={(on) => onSectionChange({ ...graph, rendering: on })}
|
||||||
/>
|
/>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
label="Extraction chunk budget"
|
label={t("Extraction chunk budget")}
|
||||||
hint={`${resolved.extractionChunkTokens} tok`}
|
hint={t("{v0} tok", { v0: resolved.extractionChunkTokens })}
|
||||||
testid="graph-row-chunk-tokens"
|
testid="graph-row-chunk-tokens"
|
||||||
>
|
>
|
||||||
<CommitInput
|
<CommitInput
|
||||||
type="number"
|
type="number"
|
||||||
value={String(graph?.extractionChunkTokens ?? "")}
|
value={String(graph?.extractionChunkTokens ?? "")}
|
||||||
placeholder={String(resolved.extractionChunkTokens)}
|
placeholder={String(resolved.extractionChunkTokens)}
|
||||||
ariaLabel="extraction chunk budget"
|
ariaLabel={t("extraction chunk budget")}
|
||||||
testid="input-chunk-tokens"
|
testid="input-chunk-tokens"
|
||||||
onCommit={(v) => {
|
onCommit={(v) => {
|
||||||
// Empty = factory default (the fix-wave discipline): absence is
|
// Empty = factory default (the fix-wave discipline): absence is
|
||||||
|
|||||||
@@ -0,0 +1,357 @@
|
|||||||
|
"use client";
|
||||||
|
import { useI18n } from "@/components/i18n";
|
||||||
|
|
||||||
|
|
||||||
|
import { createContext, useContext, useEffect, useRef, useState } from "react";
|
||||||
|
import type { ChatGPTStatus } from "@/src/core";
|
||||||
|
import { ConnectionDialog, ConnectionFooter, ConnectionRow } from "./connection-controls";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Toggle } from "./controls";
|
||||||
|
|
||||||
|
export const CHATGPT_NOTICE = "LearnHarness stores your ChatGPT account details and credentials in an unencrypted local file on this device. When this connection is used, relevant conversation context, learning material, and tool results are sent directly to OpenAI. ChatGPT plan limits apply; any additional credits are authorized and managed in ChatGPT, not LearnHarness.";
|
||||||
|
export const CHATGPT_BACKGROUND_HINT = "Background work counts toward your ChatGPT plan usage.";
|
||||||
|
const BACKGROUND_LABEL = "Allow background use for all ChatGPT accounts";
|
||||||
|
const REVOCATION_WARNING = "Account disconnected locally. Remote revocation could not be confirmed.";
|
||||||
|
export const CHATGPT_DISCONNECT_COPY = { title: "Disconnect account?", body: "This removes the saved account and its credentials from this device." };
|
||||||
|
const empty: ChatGPTStatus = { available: false, pending: false, allowBackgroundUse: true, activeId: null, accounts: [], error: null };
|
||||||
|
type Account = ChatGPTStatus["accounts"][number];
|
||||||
|
type Dialog = { kind: "connect" } | { kind: "manage"; accountId: string; account: Account; confirm?: boolean } | { kind: "warning" };
|
||||||
|
type LoginAttempt = { generation: number; dialog: Dialog; registration: string; background: boolean; backgroundDirty: boolean; pending: boolean; accountId?: string; phase: "starting" | "pending" | "consent" };
|
||||||
|
const CONTROL_ERROR = "The ChatGPT connection could not be changed. Try again.";
|
||||||
|
function isStatus(value: unknown): value is ChatGPTStatus {
|
||||||
|
if (!value || typeof value !== "object") return false;
|
||||||
|
const s = value as ChatGPTStatus;
|
||||||
|
return typeof s.available === "boolean" && typeof s.pending === "boolean" && typeof s.allowBackgroundUse === "boolean" && (s.activeId === null || typeof s.activeId === "string") &&
|
||||||
|
(s.error === null || typeof s.error === "string") && (s.catalogError == null || typeof s.catalogError === "string") && Array.isArray(s.accounts) &&
|
||||||
|
s.accounts.every((a) => a && typeof a.id === "string" && typeof a.email === "string" && typeof a.registration === "string" && typeof a.connected === "boolean" && (a.planEnabled === undefined || typeof a.planEnabled === "boolean") && (a.canSignInAgain === undefined || typeof a.canSignInAgain === "boolean"));
|
||||||
|
}
|
||||||
|
function replyError(value: unknown): string {
|
||||||
|
return value && typeof value === "object" && "error" in value && typeof value.error === "string" ? value.error : CONTROL_ERROR;
|
||||||
|
}
|
||||||
|
const Context = createContext({
|
||||||
|
status: empty,
|
||||||
|
busy: false,
|
||||||
|
error: null as string | null,
|
||||||
|
inlineError: null as string | null,
|
||||||
|
warning: null as string | null,
|
||||||
|
connect: () => {},
|
||||||
|
manage: (_id: string) => {},
|
||||||
|
select: (_id: string) => {},
|
||||||
|
dismissWarning: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** UI lifetime spans setup's keys → models transition. Only existing safe controls
|
||||||
|
* are used; account IDs remain control values, never displayed account labels. */
|
||||||
|
export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [status, setStatus] = useState<ChatGPTStatus>(empty);
|
||||||
|
const [dialog, setDialog] = useState<Dialog | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [dismissedError, setDismissedError] = useState<string | null>(null);
|
||||||
|
const [background, setBackground] = useState(true);
|
||||||
|
const [backgroundDirty, setBackgroundDirty] = useState(false);
|
||||||
|
const [registration, setRegistration] = useState("");
|
||||||
|
const operation = useRef<"login" | "cancel" | "change" | "disconnect" | null>(null);
|
||||||
|
const generation = useRef(0);
|
||||||
|
const statusSequence = useRef(0);
|
||||||
|
const mounted = useRef(false);
|
||||||
|
const dialogRef = useRef<Dialog | null>(null);
|
||||||
|
const statusRef = useRef(status);
|
||||||
|
const loginAttempt = useRef<LoginAttempt | null>(null);
|
||||||
|
const receive = useRef<(value: unknown) => void>(() => {});
|
||||||
|
function showDialog(next: Dialog | null) { dialogRef.current = next; setDialog(next); }
|
||||||
|
function publishStatus(value: unknown): ChatGPTStatus | null {
|
||||||
|
if (!isStatus(value)) { setError(replyError(value)); return null; }
|
||||||
|
statusSequence.current++; statusRef.current = value; setStatus(value);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
function owns(attempt: LoginAttempt) { return mounted.current && loginAttempt.current === attempt && generation.current === attempt.generation; }
|
||||||
|
function finish(attempt: LoginAttempt) {
|
||||||
|
if (!owns(attempt)) return;
|
||||||
|
loginAttempt.current = null; operation.current = null; setBusy(false);
|
||||||
|
}
|
||||||
|
async function complete(attempt: LoginAttempt, next: ChatGPTStatus) {
|
||||||
|
const account = next.accounts.find((a) => a.id === next.activeId && a.connected);
|
||||||
|
if (next.error || !next.available || !account || (attempt.registration && account.id !== attempt.registration)) {
|
||||||
|
finish(attempt); setError(next.error || CONTROL_ERROR); return;
|
||||||
|
}
|
||||||
|
attempt.phase = "consent"; attempt.accountId = account.id;
|
||||||
|
let replyFailure: string | null = null;
|
||||||
|
try {
|
||||||
|
if (attempt.backgroundDirty && next.allowBackgroundUse !== attempt.background) {
|
||||||
|
operation.current = "change";
|
||||||
|
const request = ++statusSequence.current;
|
||||||
|
const result: unknown = await window.lh!.chatgpt!.consent(attempt.background);
|
||||||
|
if (!owns(attempt)) return;
|
||||||
|
if (!isStatus(result)) { setError(replyError(result)); finish(attempt); return; }
|
||||||
|
replyFailure = result.error;
|
||||||
|
let saved: ChatGPTStatus | null;
|
||||||
|
if (request !== statusSequence.current) {
|
||||||
|
const refresh = ++statusSequence.current;
|
||||||
|
const value: unknown = await window.lh!.chatgpt!.status();
|
||||||
|
if (!owns(attempt)) return;
|
||||||
|
saved = refresh === statusSequence.current ? publishStatus(value) : statusRef.current;
|
||||||
|
} else saved = publishStatus(result);
|
||||||
|
if (!saved) { finish(attempt); return; }
|
||||||
|
next = saved;
|
||||||
|
}
|
||||||
|
if (!owns(attempt)) return;
|
||||||
|
const current = next.accounts.find((a) => a.id === account.id && a.id === next.activeId && a.connected);
|
||||||
|
if (replyFailure || next.error || next.pending || !next.available || !current || (attempt.backgroundDirty && next.allowBackgroundUse !== attempt.background)) setError(replyFailure || next.error || CONTROL_ERROR);
|
||||||
|
else if (dialogRef.current === attempt.dialog) showDialog(null);
|
||||||
|
} catch { if (owns(attempt)) setError(CONTROL_ERROR); }
|
||||||
|
finally { finish(attempt); }
|
||||||
|
}
|
||||||
|
function reconcile(next: ChatGPTStatus, loginReply = false) {
|
||||||
|
const attempt = loginAttempt.current;
|
||||||
|
if (!attempt || !owns(attempt)) return;
|
||||||
|
if (attempt.phase === "consent") {
|
||||||
|
if (next.pending || next.error || !next.available || next.activeId !== attempt.accountId || !next.accounts.some((a) => a.id === attempt.accountId && a.connected)) {
|
||||||
|
finish(attempt); setError(next.error || CONTROL_ERROR);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (next.pending) { attempt.pending = true; attempt.phase = "pending"; return; }
|
||||||
|
// login() normally acknowledges pending immediately; verified completion is
|
||||||
|
// a later status notification. The acknowledgement itself is not success.
|
||||||
|
if (attempt.pending || loginReply) void complete(attempt, next);
|
||||||
|
}
|
||||||
|
receive.current = (value) => {
|
||||||
|
const next = publishStatus(value);
|
||||||
|
if (next) reconcile(next);
|
||||||
|
};
|
||||||
|
useEffect(() => {
|
||||||
|
mounted.current = true;
|
||||||
|
const bridge = window.lh?.chatgpt;
|
||||||
|
const refresh = async () => {
|
||||||
|
const request = ++statusSequence.current;
|
||||||
|
try {
|
||||||
|
const next: unknown = await bridge?.status();
|
||||||
|
if (next && mounted.current && request === statusSequence.current) receive.current(next);
|
||||||
|
} catch { if (mounted.current && request === statusSequence.current) setError("The desktop connection is unavailable. Retry when the app is ready."); }
|
||||||
|
};
|
||||||
|
void refresh();
|
||||||
|
const off = bridge?.onChange(() => void refresh());
|
||||||
|
return () => { mounted.current = false; generation.current++; loginAttempt.current = null; off?.(); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const managed = dialog?.kind === "manage" ? status.accounts.find((a) => a.id === dialog.accountId) ?? dialog.account : undefined;
|
||||||
|
const managedPresent = !!managed && status.accounts.some((a) => a.id === managed.id);
|
||||||
|
const visibleError = error || (status.error !== dismissedError ? status.error : null) || status.catalogError || null;
|
||||||
|
const warning = visibleError === REVOCATION_WARNING ? visibleError : null;
|
||||||
|
function openConnect(accountId = "") {
|
||||||
|
if (operation.current) return;
|
||||||
|
setBackground(statusRef.current.allowBackgroundUse);
|
||||||
|
setBackgroundDirty(false);
|
||||||
|
setRegistration(accountId);
|
||||||
|
setError(null);
|
||||||
|
setDismissedError(status.error);
|
||||||
|
showDialog({ kind: "connect" });
|
||||||
|
}
|
||||||
|
function close() {
|
||||||
|
if (operation.current === "disconnect" || operation.current === "change" || operation.current === "cancel") return;
|
||||||
|
const current = dialogRef.current;
|
||||||
|
if (current?.kind === "manage" && current.confirm) { showDialog({ ...current, confirm: false }); setError(null); return; }
|
||||||
|
const cancel = loginAttempt.current !== null || statusRef.current.pending;
|
||||||
|
const intent = ++generation.current;
|
||||||
|
const owner = dialogRef.current;
|
||||||
|
loginAttempt.current = null;
|
||||||
|
if (!cancel) showDialog(null);
|
||||||
|
setError(null);
|
||||||
|
setDismissedError(status.error);
|
||||||
|
if (cancel) {
|
||||||
|
// Keep cancellation serialized even after login's pending IPC reply.
|
||||||
|
operation.current = "cancel"; setBusy(true);
|
||||||
|
const request = ++statusSequence.current;
|
||||||
|
void window.lh?.chatgpt?.cancel().then(async (value: unknown) => {
|
||||||
|
if (!mounted.current || generation.current !== intent) return;
|
||||||
|
if (!isStatus(value)) { setError(replyError(value)); return; }
|
||||||
|
let next: ChatGPTStatus | null;
|
||||||
|
if (request !== statusSequence.current) {
|
||||||
|
const refresh = ++statusSequence.current;
|
||||||
|
const current: unknown = await window.lh!.chatgpt!.status();
|
||||||
|
if (!mounted.current || generation.current !== intent) return;
|
||||||
|
next = refresh === statusSequence.current ? publishStatus(current) : statusRef.current;
|
||||||
|
} else next = publishStatus(value);
|
||||||
|
if (next && !next.pending) {
|
||||||
|
setDismissedError(value.error); // Dismiss only this cancellation's error, not a newer failure.
|
||||||
|
if (dialogRef.current === owner) showDialog(null);
|
||||||
|
} else if (next) setError(CONTROL_ERROR);
|
||||||
|
}).catch(() => { if (mounted.current && generation.current === intent) setError(CONTROL_ERROR); }).finally(() => {
|
||||||
|
if (mounted.current && generation.current === intent) { operation.current = null; setBusy(false); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function control(kind: "change" | "disconnect", action: () => Promise<ChatGPTStatus>, target?: string) {
|
||||||
|
if (operation.current) return;
|
||||||
|
const intent = ++generation.current;
|
||||||
|
const request = ++statusSequence.current;
|
||||||
|
const owner = dialogRef.current;
|
||||||
|
operation.current = kind;
|
||||||
|
setBusy(true); setError(null);
|
||||||
|
if (kind === "disconnect") setDismissedError(null);
|
||||||
|
try {
|
||||||
|
const value: unknown = await action();
|
||||||
|
if (!mounted.current || intent !== generation.current) return;
|
||||||
|
if (!isStatus(value)) { setError(replyError(value)); return; }
|
||||||
|
let next: ChatGPTStatus | null;
|
||||||
|
if (request !== statusSequence.current) {
|
||||||
|
const refresh = ++statusSequence.current;
|
||||||
|
const current: unknown = await window.lh!.chatgpt!.status();
|
||||||
|
if (!mounted.current || intent !== generation.current) return;
|
||||||
|
next = refresh === statusSequence.current ? publishStatus(current) : statusRef.current;
|
||||||
|
} else next = publishStatus(value);
|
||||||
|
if (!next) return;
|
||||||
|
// Dismissed status warnings belong to an earlier action. Only a new
|
||||||
|
// disconnect outcome (or an explicit IPC error envelope above) reopens one.
|
||||||
|
const failure = [value.error, next.error].find((message) => message && (kind === "disconnect" || message !== dismissedError)) ?? null;
|
||||||
|
if (kind === "disconnect" && !next.accounts.some((a) => a.id === target)) {
|
||||||
|
if (dialogRef.current === owner) {
|
||||||
|
if (failure === REVOCATION_WARNING) showDialog({ kind: "warning" });
|
||||||
|
else if (!failure) showDialog(null);
|
||||||
|
}
|
||||||
|
setError(failure);
|
||||||
|
} else setError(failure || (kind === "disconnect" ? CONTROL_ERROR : null));
|
||||||
|
} catch { if (mounted.current && intent === generation.current) setError(CONTROL_ERROR); }
|
||||||
|
finally { if (intent === generation.current) { operation.current = null; if (mounted.current) setBusy(false); } }
|
||||||
|
}
|
||||||
|
async function login() {
|
||||||
|
const bridge = window.lh?.chatgpt;
|
||||||
|
const owner = dialogRef.current;
|
||||||
|
if (!bridge || operation.current || !status.available || status.pending || owner?.kind !== "connect") return;
|
||||||
|
const attempt: LoginAttempt = { generation: ++generation.current, dialog: owner, registration, background, backgroundDirty, pending: false, phase: "starting" };
|
||||||
|
loginAttempt.current = attempt; operation.current = "login";
|
||||||
|
const request = ++statusSequence.current;
|
||||||
|
setBusy(true); setError(null); setDismissedError(null);
|
||||||
|
try {
|
||||||
|
let value: unknown = await bridge.login(registration || undefined);
|
||||||
|
if (!owns(attempt) || attempt.phase === "consent") return;
|
||||||
|
// Never publish an old acknowledgement over a newer event. If events
|
||||||
|
// coalesced past pending before the acknowledgement arrived, read the
|
||||||
|
// current backend state after it rather than losing that completion.
|
||||||
|
if (request !== statusSequence.current && isStatus(value)) {
|
||||||
|
if (value.error) { setError(value.error); finish(attempt); return; }
|
||||||
|
attempt.pending = true; attempt.phase = "pending";
|
||||||
|
const refresh = ++statusSequence.current;
|
||||||
|
value = await bridge.status();
|
||||||
|
if (!owns(attempt) || loginAttempt.current?.phase === "consent" || refresh !== statusSequence.current) return;
|
||||||
|
}
|
||||||
|
const next = publishStatus(value);
|
||||||
|
if (!next) { finish(attempt); return; }
|
||||||
|
reconcile(next, true);
|
||||||
|
} catch { if (owns(attempt)) { setError(CONTROL_ERROR); finish(attempt); } }
|
||||||
|
// No finally: pending OAuth outlives this IPC promise. Only its status
|
||||||
|
// completion, failure, or explicit cancellation releases the attempt.
|
||||||
|
}
|
||||||
|
const bridge = typeof window !== "undefined" ? window.lh?.chatgpt : undefined;
|
||||||
|
const context = {
|
||||||
|
status, busy, error: warning ? null : visibleError, inlineError: dialog || warning ? null : visibleError, warning: dialog?.kind === "warning" ? null : warning,
|
||||||
|
connect: openConnect,
|
||||||
|
manage: (id: string) => {
|
||||||
|
const account = statusRef.current.accounts.find((a) => a.id === id);
|
||||||
|
if (!operation.current && account) { setError(null); showDialog({ kind: "manage", accountId: id, account }); }
|
||||||
|
},
|
||||||
|
select: (id: string) => { if (bridge) void control("change", () => bridge.select(id)); },
|
||||||
|
dismissWarning: () => { setDismissedError(REVOCATION_WARNING); setError(null); },
|
||||||
|
};
|
||||||
|
return <Context.Provider value={context}>
|
||||||
|
{children}
|
||||||
|
{dialog && <AccountDialog key={`${dialog.kind}:${dialog.kind === "manage" && !!dialog.confirm}`} onClose={close} busy={busy && operation.current !== "login"}>
|
||||||
|
<h2 className="text-sm font-semibold text-foreground">ChatGPT</h2>
|
||||||
|
{dialog.kind === "warning" ? <>
|
||||||
|
<p role="alert" className="rounded-sm border border-border px-3 py-2 text-xs text-foreground" data-testid="chatgpt-revocation-warning">{t(REVOCATION_WARNING)}</p>
|
||||||
|
<div className="mt-4 flex justify-end"><Button variant="outline" size="sm" onClick={() => { context.dismissWarning(); showDialog(null); }}>{t("Dismiss")}</Button></div>
|
||||||
|
</> : dialog.kind === "manage" && dialog.confirm ? <div data-testid="chatgpt-disconnect-confirmation">
|
||||||
|
<p className="mt-3 text-sm font-semibold text-red-400">{t(CHATGPT_DISCONNECT_COPY.title)}</p>
|
||||||
|
<p className="mt-3 text-xs text-foreground">{managed?.email || t("ChatGPT account")}</p>
|
||||||
|
<p className="mt-3 text-xs text-muted-foreground">{t(CHATGPT_DISCONNECT_COPY.body)}</p>
|
||||||
|
{context.error && <p role="alert" className="mt-3 text-xs text-red-400">{t(context.error)}</p>}
|
||||||
|
<div className="mt-4 flex items-center justify-end gap-2">
|
||||||
|
<Button variant="ghost" size="sm" disabled={busy} onClick={close}>{t("Cancel")}</Button>
|
||||||
|
<Button variant="destructive" size="sm" data-testid="chatgpt-confirm-disconnect" disabled={busy || !managedPresent} onClick={() => bridge && managed && void control("disconnect", () => bridge.disconnect(managed.id), managed.id)}>{t("Disconnect account")}</Button>
|
||||||
|
</div>
|
||||||
|
</div> : <>
|
||||||
|
{dialog.kind === "connect" ? <>
|
||||||
|
{status.accounts.length > 0 && <div className="mt-3 flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<select aria-label={t("Saved ChatGPT account")} value={registration} onChange={(event) => setRegistration(event.target.value)} disabled={busy || status.pending} className="min-w-0 rounded-sm border border-border bg-background px-2 py-1.5 text-foreground">
|
||||||
|
<option value="">{t("Add another account or workspace")}</option>
|
||||||
|
{status.accounts.map((a) => <option key={a.id} value={a.id}>{a.email || t("ChatGPT account")}</option>)}
|
||||||
|
</select>
|
||||||
|
{registration && <Button variant="ghost" size="xs" disabled={busy} onClick={() => context.manage(registration)}>{t("Manage…")}</Button>}
|
||||||
|
</div>}
|
||||||
|
<p className="mt-3 text-xs leading-relaxed text-muted-foreground" data-testid="chatgpt-privacy-notice">{t(CHATGPT_NOTICE)}</p>
|
||||||
|
</> : null}
|
||||||
|
<div className={`mt-4 flex items-center gap-3 text-xs text-foreground ${busy && operation.current === "change" && status.available && !status.pending ? "[&>button:disabled]:cursor-auto" : ""}`} data-testid="chatgpt-global-background">
|
||||||
|
<Toggle label={t(BACKGROUND_LABEL)} checked={dialog.kind === "connect" ? background : status.allowBackgroundUse} disabled={busy || status.pending || !status.available} onChange={(enabled) => {
|
||||||
|
if (dialog.kind === "connect") { setBackground(enabled); setBackgroundDirty(true); }
|
||||||
|
else if (bridge) void control("change", () => bridge.consent(enabled));
|
||||||
|
}} />
|
||||||
|
<span>{t(BACKGROUND_LABEL)}<span className="mt-1 block leading-relaxed text-muted-foreground">{t(CHATGPT_BACKGROUND_HINT)}</span></span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
|
||||||
|
<a href="https://openai.com/policies/privacy-policy/" target="_blank" rel="noreferrer" className="underline underline-offset-2">{t("OpenAI privacy")}</a>
|
||||||
|
<a href="https://chatgpt.com/settings/usage" target="_blank" rel="noreferrer" className="underline underline-offset-2">{t("Manage usage")}</a>
|
||||||
|
</div>
|
||||||
|
{!status.available && <p className="mt-3 text-xs text-muted-foreground">{t("ChatGPT sign-in is available only in the desktop app. API-key connections remain available here.")}</p>}
|
||||||
|
{status.pending && <p role="status" className="mt-3 text-xs text-muted-foreground">{t("Waiting for browser sign-in…")}</p>}
|
||||||
|
{context.error && <p role="alert" className="mt-3 text-xs text-red-400">{t(context.error)}</p>}
|
||||||
|
{dialog.kind === "manage" && managed && <div className="mt-4 flex flex-wrap items-center justify-between gap-3" data-testid="chatgpt-account-strip">
|
||||||
|
<div data-testid="chatgpt-managed-identity" className="flex min-w-0 items-center gap-2 text-xs text-foreground">
|
||||||
|
{status.accounts.length > 1 && managedPresent && !busy ? <select aria-label={t("Manage ChatGPT account")} value={managed.id} onChange={(event) => context.manage(event.target.value)} className="min-w-0 rounded-sm border border-border bg-background px-2 py-1.5">
|
||||||
|
{status.accounts.map((a) => <option key={a.id} value={a.id}>{a.email || t("ChatGPT account")}</option>)}
|
||||||
|
</select> : <span className="break-all">{managed.email || t("ChatGPT account")}</span>}
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" data-testid="chatgpt-add-accounts" className="cursor-pointer text-muted-foreground disabled:cursor-not-allowed" onClick={() => openConnect()} disabled={busy || status.pending || !status.available}>{t("Add accounts")}</Button>
|
||||||
|
</div>}
|
||||||
|
<ConnectionFooter testid="chatgpt-account-footer" leading={dialog.kind === "manage" && <div data-testid="chatgpt-account-actions">
|
||||||
|
<Button variant="ghost" size="sm" className="cursor-pointer text-red-400 hover:bg-red-500/10 hover:text-red-300" data-testid="chatgpt-disconnect" disabled={busy || status.pending || !managedPresent} onClick={() => showDialog({ ...dialog, confirm: true })}>{t("Disconnect")}</Button>
|
||||||
|
</div>}>
|
||||||
|
{dialog.kind === "manage" && managedPresent && managed && !managed.connected && managed.canSignInAgain === true && status.available && <Button variant="outline" size="sm" disabled={busy || status.pending || !status.available} onClick={() => openConnect(managed.id)}>{t("Sign in again")}</Button>}
|
||||||
|
<Button variant={dialog.kind === "manage" ? "outline" : "ghost"} size="sm" onClick={close} disabled={busy && operation.current !== "login"}>{dialog.kind === "connect" ? t("Cancel") : t("Close")}</Button>
|
||||||
|
{dialog.kind === "connect" && <ContinueButton onClick={() => void login()} disabled={!status.available || busy || status.pending} testid="chatgpt-proceed" />}
|
||||||
|
</ConnectionFooter>
|
||||||
|
</>}
|
||||||
|
</AccountDialog>}
|
||||||
|
</Context.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContinueButton({ onClick, disabled, testid = "chatgpt-login" }: { onClick: () => void; disabled: boolean; testid?: string }) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
return <Button type="button" variant="outline" size="sm" data-testid={testid} onClick={onClick} disabled={disabled} className="cursor-pointer disabled:cursor-not-allowed">
|
||||||
|
{/* Official unmodified mark; existing secondary button hover/focus treatment. */}
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img src="/siwc/chatgpt-logo-white.svg" width="16" height="16" alt="" aria-hidden="true" />
|
||||||
|
{t("Continue with ChatGPT")}</Button>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccountDialog({ children, onClose, busy }: { children: React.ReactNode; onClose: () => void; busy: boolean }) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
return <ConnectionDialog label={t("ChatGPT account")} testid="chatgpt-account-dialog" backdropTestid="chatgpt-dialog-backdrop" onClose={onClose} busy={busy}>{children}</ConnectionDialog>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shared compact settings/setup row; account selection precedes the quiet action. */
|
||||||
|
export function ChatGPTConnection(_props: { setup?: boolean } = {}) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const ui = useContext(Context);
|
||||||
|
const active = ui.status.accounts.find((a) => a.id === ui.status.activeId && a.connected);
|
||||||
|
const accounts = ui.status.accounts.filter((a) => a.connected);
|
||||||
|
return <div className="flex flex-col gap-2" data-testid="chatgpt-connection">
|
||||||
|
<ConnectionRow label="ChatGPT" actionTestid={active ? undefined : "chatgpt-login"} action={active ? t("Manage…") : t("Connect")} disabled={!ui.status.available || ui.busy || ui.status.pending} onAction={() => active ? ui.manage(active.id) : ui.connect()}>
|
||||||
|
{active && <select aria-label={t("ChatGPT account")} value={active.id} disabled={ui.busy || ui.status.pending} onChange={(event) => ui.select(event.target.value)} className="min-w-0 rounded-sm border border-border bg-background px-2 py-1 text-xs text-foreground">
|
||||||
|
{accounts.map((a) => <option key={a.id} value={a.id}>{a.email || t("ChatGPT account")}</option>)}
|
||||||
|
</select>}
|
||||||
|
</ConnectionRow>
|
||||||
|
{ui.inlineError && <p role="alert" className="text-xs text-red-400">{ui.inlineError}</p>}
|
||||||
|
{ui.warning && <div role="alert" data-testid="chatgpt-revocation-warning" className="rounded-sm border border-border px-3 py-2 text-xs text-muted-foreground">{ui.warning}<button type="button" className="ml-2 cursor-pointer underline underline-offset-2" onClick={ui.dismissWarning}>{t("Dismiss")}</button></div>}
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Contextual consent discovery belongs beside background model choices only. */
|
||||||
|
export function ChatGPTBackgroundAccess({ provider }: { provider: string | undefined }) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const ui = useContext(Context);
|
||||||
|
const account: Account | undefined = ui.status.accounts.find((a) => a.id === ui.status.activeId && a.connected);
|
||||||
|
if (provider !== "chatgpt" || !account || ui.status.allowBackgroundUse) return null;
|
||||||
|
return <Button type="button" size="xs" variant="ghost" className="cursor-pointer" disabled={ui.busy} onClick={() => ui.manage(account.id)}>{t("Enable background use")}</Button>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useI18n } from "@/components/i18n";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { useBackdropDismiss } from "@/components/backdrop-dismiss";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
export function ConnectionRow({ label, action, disabled, onAction, children, testid, actionTestid }: { label: string; action: string; disabled: boolean; onAction: () => void; children?: React.ReactNode; testid?: string; actionTestid?: string }) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
return <div className="flex flex-wrap items-center gap-3" data-testid={testid}>
|
||||||
|
<span className="font-ui text-xs text-foreground">{label}</span>
|
||||||
|
{children}
|
||||||
|
<Button type="button" size="xs" variant={action === t("Connect") ? "outline" : "ghost"} className={`cursor-pointer disabled:cursor-not-allowed ${action === t("Connect") ? "" : "text-muted-foreground hover:text-foreground"}`} data-testid={actionTestid} disabled={disabled} onClick={onAction}>{action}</Button>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConnectionFooter({ leading, children, testid }: { leading?: React.ReactNode; children: React.ReactNode; testid?: string }) {
|
||||||
|
return <div className="mt-4 flex flex-wrap items-center justify-between gap-3" data-testid={testid}>
|
||||||
|
{leading}
|
||||||
|
<div className="ml-auto flex flex-wrap items-center justify-end gap-2">{children}</div>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConnectionDialog({ children, onClose, busy, label, testid, backdropTestid }: { children: React.ReactNode; onClose: () => void; busy: boolean; label: string; testid: string; backdropTestid: string }) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null), closeRef = useRef(onClose);
|
||||||
|
closeRef.current = onClose;
|
||||||
|
const backdropDismiss = useBackdropDismiss(() => { if (!busy) onClose(); });
|
||||||
|
useEffect(() => {
|
||||||
|
const previous = document.activeElement as HTMLElement | null;
|
||||||
|
const focusable = () => [...(ref.current?.querySelectorAll<HTMLElement>('button:not([disabled]), a[href], input:not([disabled]), select:not([disabled])') ?? [])];
|
||||||
|
focusable()[0]?.focus();
|
||||||
|
const key = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") { event.preventDefault(); event.stopImmediatePropagation(); closeRef.current(); }
|
||||||
|
else if (event.key === "Tab") {
|
||||||
|
const items = focusable(), first = items[0], last = items.at(-1);
|
||||||
|
if (!first) { event.preventDefault(); return; }
|
||||||
|
if (event.shiftKey && (document.activeElement === first || !ref.current?.contains(document.activeElement))) { event.preventDefault(); last?.focus(); }
|
||||||
|
else if (!event.shiftKey && (document.activeElement === last || !ref.current?.contains(document.activeElement))) { event.preventDefault(); first.focus(); }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", key, true);
|
||||||
|
return () => { document.removeEventListener("keydown", key, true); if (previous?.isConnected) previous.focus(); };
|
||||||
|
}, []);
|
||||||
|
return createPortal(<div className="fixed inset-0 z-[60] flex items-center justify-center bg-background/80" data-testid={backdropTestid} {...backdropDismiss}>
|
||||||
|
<div ref={ref} role="dialog" aria-modal="true" aria-label={label} aria-busy={busy} data-testid={testid} onClick={(event) => event.stopPropagation()} className="settings-dialog-pop mx-4 max-h-[85vh] w-full max-w-lg overflow-y-auto rounded-md border border-border bg-card p-5 shadow-xl">{children}</div>
|
||||||
|
</div>, document.body);
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useI18n } from "@/components/i18n";
|
||||||
/**
|
/**
|
||||||
* Thin settings primitives (settings-app wave): the control kit the
|
* Thin settings primitives (settings-app wave): the control kit the
|
||||||
* settings dialog needs. Sections → groups → rows; everything SSRs clean
|
* settings dialog needs. Sections → groups → rows; everything SSRs clean
|
||||||
@@ -25,10 +27,11 @@ export function SettingsSection({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
testid?: string;
|
testid?: string;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
return (
|
return (
|
||||||
<section data-testid={testid} className="px-6 py-5">
|
<section data-testid={testid} className="px-6 py-5">
|
||||||
<h2 className="text-sm uppercase tracking-widest text-foreground">{title}</h2>
|
<h2 className="text-sm uppercase tracking-widest text-foreground">{t(title)}</h2>
|
||||||
{hint && <p className="mt-1 text-xs text-muted-foreground">{hint}</p>}
|
{hint && <p className="mt-1 text-xs text-muted-foreground">{t(hint)}</p>}
|
||||||
<div className="mt-4 flex flex-col gap-3">{children}</div>
|
<div className="mt-4 flex flex-col gap-3">{children}</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
@@ -45,9 +48,10 @@ export function SettingsGroup({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
testid?: string;
|
testid?: string;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
return (
|
return (
|
||||||
<div data-testid={testid} className="mt-5 border-t border-border/60 pt-4 first:mt-0 first:border-t-0 first:pt-0">
|
<div data-testid={testid} className="mt-5 border-t border-border/60 pt-4 first:mt-0 first:border-t-0 first:pt-0">
|
||||||
<p className="mb-2 text-xs uppercase tracking-widest text-muted-foreground">{label}</p>
|
<p className="mb-2 text-xs uppercase tracking-widest text-muted-foreground">{t(label)}</p>
|
||||||
<div className="flex flex-col gap-2">{children}</div>
|
<div className="flex flex-col gap-2">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -61,6 +65,7 @@ export function SettingsRow({
|
|||||||
children,
|
children,
|
||||||
testid,
|
testid,
|
||||||
dimmed,
|
dimmed,
|
||||||
|
className,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
hint?: string;
|
hint?: string;
|
||||||
@@ -69,12 +74,14 @@ export function SettingsRow({
|
|||||||
/** Dependency graying, graph-view style: the whole row dims when the
|
/** Dependency graying, graph-view style: the whole row dims when the
|
||||||
* control is inert (parent off, no file yet, …). */
|
* control is inert (parent off, no file yet, …). */
|
||||||
dimmed?: boolean;
|
dimmed?: boolean;
|
||||||
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
return (
|
return (
|
||||||
<div data-testid={testid} className={cn("flex items-center justify-between gap-4 py-1", dimmed && "opacity-40")}>
|
<div data-testid={testid} className={cn("flex items-center justify-between gap-4 py-1", dimmed && "opacity-40", className)}>
|
||||||
<span className="min-w-0">
|
<span className="min-w-0">
|
||||||
<span className="block font-prose text-(length:--lh-prose-size)">{label}</span>
|
<span className="block font-prose text-(length:--lh-prose-size)">{t(label)}</span>
|
||||||
{hint && <span className="block text-xs text-muted-foreground/70">{hint}</span>}
|
{hint && <span className="block text-xs text-muted-foreground/70">{t(hint)}</span>}
|
||||||
</span>
|
</span>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
@@ -102,13 +109,14 @@ export function Toggle({
|
|||||||
/** Optional native tooltip on the switch. */
|
/** Optional native tooltip on the switch. */
|
||||||
title?: string;
|
title?: string;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="switch"
|
role="switch"
|
||||||
aria-checked={checked}
|
aria-checked={checked}
|
||||||
aria-label={label}
|
aria-label={t(label)}
|
||||||
{...(title ? { title } : {})}
|
{...(title ? { title: t(title) } : {})}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
data-testid={`toggle-${label.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`}
|
data-testid={`toggle-${label.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`}
|
||||||
onClick={() => onChange(!checked)}
|
onClick={() => onChange(!checked)}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { useI18n } from "@/components/i18n";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The Keyboard section (thin settings wave): view / rebind / reset over
|
* The Keyboard section (thin settings wave): view / rebind / reset over
|
||||||
* core/keyboard.ts's registry. The overrides persist in localStorage
|
* core/keyboard.ts's registry. The overrides persist in localStorage
|
||||||
@@ -48,6 +50,7 @@ export function nextOverrides(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function KeyboardSection() {
|
export function KeyboardSection() {
|
||||||
|
const { t } = useI18n();
|
||||||
const [overrides, setOverrides] = useState<Partial<Record<KeyAction, string>>>({});
|
const [overrides, setOverrides] = useState<Partial<Record<KeyAction, string>>>({});
|
||||||
const [rebinding, setRebinding] = useState<KeyAction | null>(null);
|
const [rebinding, setRebinding] = useState<KeyAction | null>(null);
|
||||||
const [nonce, setNonce] = useState(0);
|
const [nonce, setNonce] = useState(0);
|
||||||
@@ -101,20 +104,20 @@ export function KeyboardSection() {
|
|||||||
if (!actions.length) return null;
|
if (!actions.length) return null;
|
||||||
return (
|
return (
|
||||||
<div key={scope} data-testid={`kbd-scope-${scope}`}>
|
<div key={scope} data-testid={`kbd-scope-${scope}`}>
|
||||||
<p className="mb-1 text-xs uppercase tracking-widest text-muted-foreground/70">{label}</p>
|
<p className="mb-1 text-xs uppercase tracking-widest text-muted-foreground/70">{t(label)}</p>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{actions.map((def) => {
|
{actions.map((def) => {
|
||||||
const effective = overrides[def.id] ?? def.binding;
|
const effective = overrides[def.id] ?? def.binding;
|
||||||
return (
|
return (
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
key={def.id}
|
key={def.id}
|
||||||
label={def.label}
|
label={t(def.label)}
|
||||||
hint={def.displayOnly ? "Fixed — part of this screen" : undefined}
|
hint={def.displayOnly ? t("Fixed — part of this screen") : undefined}
|
||||||
testid={`kbd-row-${def.id}`}
|
testid={`kbd-row-${def.id}`}
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<kbd className="rounded-sm border border-border bg-muted px-1.5 py-0.5 font-ui text-xs">
|
<kbd className="rounded-sm border border-border bg-muted px-1.5 py-0.5 font-ui text-xs">
|
||||||
{rebinding === def.id ? "Press keys…" : formatBinding(effective)}
|
{rebinding === def.id ? t("Press keys…") : t(formatBinding(effective))}
|
||||||
</kbd>
|
</kbd>
|
||||||
{def.displayOnly ? null : rebinding === def.id ? (
|
{def.displayOnly ? null : rebinding === def.id ? (
|
||||||
<button
|
<button
|
||||||
@@ -122,8 +125,7 @@ export function KeyboardSection() {
|
|||||||
onClick={() => setRebinding(null)}
|
onClick={() => setRebinding(null)}
|
||||||
className="text-xs text-muted-foreground/70 underline-offset-2 hover:text-foreground hover:underline"
|
className="text-xs text-muted-foreground/70 underline-offset-2 hover:text-foreground hover:underline"
|
||||||
>
|
>
|
||||||
Cancel
|
{t("Cancel")}</button>
|
||||||
</button>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
@@ -132,8 +134,7 @@ export function KeyboardSection() {
|
|||||||
data-testid={`kbd-rebind-${def.id}`}
|
data-testid={`kbd-rebind-${def.id}`}
|
||||||
className="text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
|
className="text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
|
||||||
>
|
>
|
||||||
Rebind
|
{t("Rebind")}</button>
|
||||||
</button>
|
|
||||||
{overridden(def.id) && (
|
{overridden(def.id) && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -141,8 +142,7 @@ export function KeyboardSection() {
|
|||||||
data-testid={`kbd-reset-${def.id}`}
|
data-testid={`kbd-reset-${def.id}`}
|
||||||
className="text-xs text-muted-foreground/70 underline-offset-2 hover:text-foreground hover:underline"
|
className="text-xs text-muted-foreground/70 underline-offset-2 hover:text-foreground hover:underline"
|
||||||
>
|
>
|
||||||
Reset
|
{t("Reset")}</button>
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -155,15 +155,14 @@ export function KeyboardSection() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<div className="mt-1 flex items-center justify-between">
|
<div className="mt-1 flex items-center justify-between">
|
||||||
<span className="text-xs text-muted-foreground/70">Stored on this device (lh:keymap) — overrides only.</span>
|
<span className="text-xs text-muted-foreground/70">{t("Stored on this device (lh:keymap) — overrides only.")}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={resetAll}
|
onClick={resetAll}
|
||||||
data-testid="kbd-reset-all"
|
data-testid="kbd-reset-all"
|
||||||
className="text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
|
className="text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
|
||||||
>
|
>
|
||||||
Reset all to defaults
|
{t("Reset all to defaults")}</button>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { translator, type Translator } from "@/src/core";
|
||||||
|
import { useI18n } from "@/components/i18n";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The Keys section (wave-5 polish, 2026-09-21 — owner design): a MINIMAL
|
* The Keys section (wave-5 polish, 2026-09-21 — owner design): a MINIMAL
|
||||||
* key-value table. Provider left, masked key right, no description hints
|
* key-value table. Provider left, masked key right, no description hints
|
||||||
@@ -15,8 +18,8 @@
|
|||||||
* a paste box on a key the UI cannot make live would mislead.
|
* a paste box on a key the UI cannot make live would mislead.
|
||||||
* - OpenRouter is NOT special: one provider among 33, a row only when
|
* - OpenRouter is NOT special: one provider among 33, a row only when
|
||||||
* stored, otherwise it lives in the Add dropdown like everyone else.
|
* stored, otherwise it lives in the Add dropdown like everyone else.
|
||||||
* - Tools & services (Deepgram, the Google pair) keep their own group,
|
* - Providers groups API keys, subscriptions, dictation and search.
|
||||||
* same look, same interaction.
|
* Unknown variables stay masked/read-only at the end of Model API keys.
|
||||||
*
|
*
|
||||||
* API: GET /api/keys (masked statuses + the providers array), PUT
|
* API: GET /api/keys (masked statuses + the providers array), PUT
|
||||||
* /api/keys/:id (shape-gated 400s land inline), DELETE. Saves take
|
* /api/keys/:id (shape-gated 400s land inline), DELETE. Saves take
|
||||||
@@ -26,6 +29,7 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { AUTH_KEYS, type AuthKeyStatus } from "@/src/core";
|
import { AUTH_KEYS, type AuthKeyStatus } from "@/src/core";
|
||||||
import { SettingsGroup } from "./controls.tsx";
|
import { SettingsGroup } from "./controls.tsx";
|
||||||
|
import { SubscriptionConnections } from "./subscription-connections.tsx";
|
||||||
import { ProviderMenuList } from "./model-menu.tsx";
|
import { ProviderMenuList } from "./model-menu.tsx";
|
||||||
import { MenuShell } from "@/components/menu-shell";
|
import { MenuShell } from "@/components/menu-shell";
|
||||||
import { notifySettingsSaved } from "@/components/settings-signal";
|
import { notifySettingsSaved } from "@/components/settings-signal";
|
||||||
@@ -97,11 +101,11 @@ export function classifyKeys(
|
|||||||
|
|
||||||
/** The minimal status line, e.g. "…ab12 · 64 chars" (" · env" marks an
|
/** The minimal status line, e.g. "…ab12 · 64 chars" (" · env" marks an
|
||||||
* environment-owned value — the row itself is read-only then). Pure. */
|
* environment-owned value — the row itself is read-only then). Pure. */
|
||||||
export function keyStatusLine(status: AuthKeyStatus): string {
|
export function keyStatusLine(status: AuthKeyStatus, t: Translator = translator("en")): string {
|
||||||
if (!status.present || !status.masked) return "not set";
|
if (!status.present || !status.masked) return t("not set");
|
||||||
const tail = status.masked.replace(/ \((\d+) chars\)$/, "");
|
const tail = status.masked.replace(/ \((\d+) chars\)$/, "");
|
||||||
const chars = status.masked.match(/\((\d+) chars\)$/)?.[1] ?? "?";
|
const chars = status.masked.match(/\((\d+) chars\)$/)?.[1] ?? "?";
|
||||||
return `${tail} · ${chars}${status.source === "env" ? " · env" : ""}`;
|
return `${tail} · ${chars}${status.source === "env" ? t(" · env") : ""}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One table row: label left, tiny docs link + status right. Writable
|
/** One table row: label left, tiny docs link + status right. Writable
|
||||||
@@ -131,6 +135,7 @@ function KeyTableRow({
|
|||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
testid: string;
|
testid: string;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
const envOwned = status.source === "env";
|
const envOwned = status.source === "env";
|
||||||
const [value, setValue] = useState("");
|
const [value, setValue] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -162,8 +167,8 @@ function KeyTableRow({
|
|||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") void save();
|
if (e.key === "Enter") void save();
|
||||||
}}
|
}}
|
||||||
placeholder="Paste key…"
|
placeholder={t("Paste key…")}
|
||||||
aria-label={`${label} key`}
|
aria-label={t("{v0} key", { v0: label })}
|
||||||
data-testid={`${testid}-input`}
|
data-testid={`${testid}-input`}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
autoFocus
|
autoFocus
|
||||||
@@ -176,8 +181,7 @@ function KeyTableRow({
|
|||||||
data-testid={`${testid}-save`}
|
data-testid={`${testid}-save`}
|
||||||
className="shrink-0 text-xs text-foreground underline-offset-2 hover:underline disabled:text-muted-foreground/40 disabled:no-underline"
|
className="shrink-0 text-xs text-foreground underline-offset-2 hover:underline disabled:text-muted-foreground/40 disabled:no-underline"
|
||||||
>
|
>
|
||||||
Save
|
{t("Save")}</button>
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
@@ -185,8 +189,7 @@ function KeyTableRow({
|
|||||||
data-testid={`${testid}-cancel`}
|
data-testid={`${testid}-cancel`}
|
||||||
className="shrink-0 text-xs text-muted-foreground/70 underline-offset-2 hover:text-foreground hover:underline"
|
className="shrink-0 text-xs text-muted-foreground/70 underline-offset-2 hover:text-foreground hover:underline"
|
||||||
>
|
>
|
||||||
Cancel
|
{t("Cancel")}</button>
|
||||||
</button>
|
|
||||||
{status.present && status.source === "file" && onDelete && (
|
{status.present && status.source === "file" && onDelete && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -195,13 +198,12 @@ function KeyTableRow({
|
|||||||
data-testid={`${testid}-delete`}
|
data-testid={`${testid}-delete`}
|
||||||
className="shrink-0 text-xs text-muted-foreground/70 underline-offset-2 hover:text-red-500 hover:underline"
|
className="shrink-0 text-xs text-muted-foreground/70 underline-offset-2 hover:text-red-500 hover:underline"
|
||||||
>
|
>
|
||||||
Delete
|
{t("Delete")}</button>
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{error && (
|
{error && (
|
||||||
<p role="alert" data-testid={`${testid}-error`} className="px-1 text-xs text-red-500">
|
<p role="alert" data-testid={`${testid}-error`} className="px-1 text-xs text-red-500">
|
||||||
{error}
|
{t(error)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -229,17 +231,16 @@ function KeyTableRow({
|
|||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="text-[10px] text-muted-foreground/60 underline-offset-2 hover:text-foreground hover:underline"
|
className="text-[10px] text-muted-foreground/60 underline-offset-2 hover:text-foreground hover:underline"
|
||||||
>
|
>
|
||||||
key ↗
|
{t("key ↗")}</a>
|
||||||
</a>
|
|
||||||
)}
|
)}
|
||||||
<span data-testid={`${testid}-status`} className="font-mono text-[11px] text-muted-foreground">
|
<span data-testid={`${testid}-status`} className="font-mono text-[11px] text-muted-foreground">
|
||||||
{keyStatusLine(status)}
|
{keyStatusLine(status, t)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{error && (
|
{error && (
|
||||||
<p role="alert" data-testid={`${testid}-error`} className="pl-6 text-xs text-red-500">
|
<p role="alert" data-testid={`${testid}-error`} className="pl-6 text-xs text-red-500">
|
||||||
{error}
|
{t(error)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -255,6 +256,7 @@ function AddProviderRow({
|
|||||||
addable: ProviderKeyInfo[];
|
addable: ProviderKeyInfo[];
|
||||||
onPick: (p: ProviderKeyInfo) => void;
|
onPick: (p: ProviderKeyInfo) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const anchorRef = useRef<HTMLDivElement>(null);
|
const anchorRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -289,7 +291,7 @@ function AddProviderRow({
|
|||||||
onClick={() => setOpen((v) => !v)}
|
onClick={() => setOpen((v) => !v)}
|
||||||
className="flex w-full items-center justify-between gap-3 rounded-sm px-1 py-1.5 text-left font-ui text-xs text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
|
className="flex w-full items-center justify-between gap-3 rounded-sm px-1 py-1.5 text-left font-ui text-xs text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
|
||||||
>
|
>
|
||||||
<span>+ Add provider key</span>
|
<span>{t("+ Add provider key")}</span>
|
||||||
{addable.length > 0 && <span className="font-mono text-[11px]">{addable.length}</span>}
|
{addable.length > 0 && <span className="font-mono text-[11px]">{addable.length}</span>}
|
||||||
</button>
|
</button>
|
||||||
{open && (
|
{open && (
|
||||||
@@ -323,6 +325,7 @@ export function KeysSection({
|
|||||||
}: {
|
}: {
|
||||||
initialRow?: string;
|
initialRow?: string;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
const [statuses, setStatuses] = useState<AuthKeyStatus[] | null>(null);
|
const [statuses, setStatuses] = useState<AuthKeyStatus[] | null>(null);
|
||||||
const [providers, setProviders] = useState<ProviderKeyInfo[] | undefined>(undefined);
|
const [providers, setProviders] = useState<ProviderKeyInfo[] | undefined>(undefined);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -396,7 +399,7 @@ export function KeysSection({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (loading || statuses === null) {
|
if (loading || statuses === null) {
|
||||||
return <p className="px-6 py-5 text-xs text-muted-foreground/70">Loading keys…</p>;
|
return <p className="px-6 py-5 text-xs text-muted-foreground/70">{t("Loading keys…")}</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { providerRows, toolRows, unknownRows, addable } = classifyKeys(statuses, providers);
|
const { providerRows, toolRows, unknownRows, addable } = classifyKeys(statuses, providers);
|
||||||
@@ -412,7 +415,7 @@ export function KeysSection({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SettingsGroup label="Model providers" testid="group-provider-keys">
|
<SettingsGroup label={t("Model API keys")} testid="group-provider-keys">
|
||||||
{providerRows.map((status) => (
|
{providerRows.map((status) => (
|
||||||
<KeyTableRow
|
<KeyTableRow
|
||||||
key={status.id}
|
key={status.id}
|
||||||
@@ -446,10 +449,20 @@ export function KeysSection({
|
|||||||
<AddProviderEditor provider={adding} onSave={(v) => void saveNewProvider(adding, v)} onCancel={() => setAdding(null)} error={addError} />
|
<AddProviderEditor provider={adding} onSave={(v) => void saveNewProvider(adding, v)} onCancel={() => setAdding(null)} error={addError} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{unknownRows.length > 0 && <div data-testid="group-unknown-keys">
|
||||||
|
{unknownRows.map((status) => <div key={status.id} data-testid={`unknown-${status.id}`} className="flex items-center justify-between gap-3 px-1 py-1.5 opacity-40">
|
||||||
|
<span className="font-mono text-xs text-foreground">{status.id}</span>
|
||||||
|
<span className="font-mono text-[11px] text-muted-foreground">{status.masked}</span>
|
||||||
|
</div>)}
|
||||||
|
</div>}
|
||||||
</SettingsGroup>
|
</SettingsGroup>
|
||||||
|
|
||||||
<SettingsGroup label="Tools & services" testid="group-tool-keys">
|
<SettingsGroup label={t("Subscriptions")} testid="group-subscriptions">
|
||||||
{toolRows.map((status) => (
|
<SubscriptionConnections />
|
||||||
|
</SettingsGroup>
|
||||||
|
|
||||||
|
{(["Dictation", "Search"] as const).map((label) => <SettingsGroup key={label} label={label} testid={label === "Dictation" ? "group-dictation" : "group-search"}>
|
||||||
|
{toolRows.filter((status) => label === "Dictation" ? status.id === "DEEPGRAM_API_KEY" : status.id !== "DEEPGRAM_API_KEY").map((status) => (
|
||||||
<KeyTableRow
|
<KeyTableRow
|
||||||
key={status.id}
|
key={status.id}
|
||||||
label={status.info?.label ?? status.id}
|
label={status.info?.label ?? status.id}
|
||||||
@@ -467,22 +480,8 @@ export function KeysSection({
|
|||||||
testid={`key-${status.id.toLowerCase()}`}
|
testid={`key-${status.id.toLowerCase()}`}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</SettingsGroup>
|
</SettingsGroup>)}
|
||||||
|
|
||||||
{unknownRows.length > 0 && (
|
|
||||||
<SettingsGroup label="Other stored variables" testid="group-unknown-keys">
|
|
||||||
{unknownRows.map((status) => (
|
|
||||||
<div
|
|
||||||
key={status.id}
|
|
||||||
data-testid={`unknown-${status.id}`}
|
|
||||||
className="flex items-center justify-between gap-3 px-1 py-1.5 opacity-40"
|
|
||||||
>
|
|
||||||
<span className="font-mono text-xs text-foreground">{status.id}</span>
|
|
||||||
<span className="font-mono text-[11px] text-muted-foreground">{status.masked}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</SettingsGroup>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -500,6 +499,7 @@ function AddProviderEditor({
|
|||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
const [value, setValue] = useState("");
|
const [value, setValue] = useState("");
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -510,8 +510,8 @@ function AddProviderEditor({
|
|||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" && value.trim()) onSave(value);
|
if (e.key === "Enter" && value.trim()) onSave(value);
|
||||||
}}
|
}}
|
||||||
placeholder="Paste key…"
|
placeholder={t("Paste key…")}
|
||||||
aria-label={`${provider.label} key`}
|
aria-label={t("{v0} key", { v0: provider.label })}
|
||||||
data-testid={`key-${provider.id}-input`}
|
data-testid={`key-${provider.id}-input`}
|
||||||
autoFocus
|
autoFocus
|
||||||
className="h-7 min-w-0 flex-1 rounded-sm border border-border bg-background px-1.5 font-mono text-xs text-foreground outline-none placeholder:text-muted-foreground/60 focus:border-muted-foreground/50"
|
className="h-7 min-w-0 flex-1 rounded-sm border border-border bg-background px-1.5 font-mono text-xs text-foreground outline-none placeholder:text-muted-foreground/60 focus:border-muted-foreground/50"
|
||||||
@@ -523,19 +523,17 @@ function AddProviderEditor({
|
|||||||
data-testid={`key-${provider.id}-save`}
|
data-testid={`key-${provider.id}-save`}
|
||||||
className="shrink-0 text-xs text-foreground underline-offset-2 hover:underline disabled:text-muted-foreground/40 disabled:no-underline"
|
className="shrink-0 text-xs text-foreground underline-offset-2 hover:underline disabled:text-muted-foreground/40 disabled:no-underline"
|
||||||
>
|
>
|
||||||
Save
|
{t("Save")}</button>
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
data-testid={`key-${provider.id}-cancel`}
|
data-testid={`key-${provider.id}-cancel`}
|
||||||
className="shrink-0 text-xs text-muted-foreground/70 underline-offset-2 hover:text-foreground hover:underline"
|
className="shrink-0 text-xs text-muted-foreground/70 underline-offset-2 hover:text-foreground hover:underline"
|
||||||
>
|
>
|
||||||
Cancel
|
{t("Cancel")}</button>
|
||||||
</button>
|
|
||||||
{error && (
|
{error && (
|
||||||
<p role="alert" className="w-full pl-0 text-xs text-red-500" data-testid={`key-${provider.id}-error`}>
|
<p role="alert" className="w-full pl-0 text-xs text-red-500" data-testid={`key-${provider.id}-error`}>
|
||||||
{error}
|
{t(error)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { useI18n } from "@/components/i18n";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The model menu (extracted 2026-09-20 from model-picker.tsx, owner ask:
|
* The model menu (extracted 2026-09-20 from model-picker.tsx, owner ask:
|
||||||
* the agent settings' role selects use the EXACT same dropdown as the
|
* the agent settings' role selects use the EXACT same dropdown as the
|
||||||
@@ -21,7 +23,7 @@
|
|||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import type { ModelCatalogEntry } from "@/src/core";
|
import type { ModelCatalogEntry } from "@/src/core";
|
||||||
import { fuzzyScore } from "@/src/core";
|
import { fuzzyScore, subscriptionLabel } from "@/src/core";
|
||||||
import { formatTokens } from "@/components/usage-format";
|
import { formatTokens } from "@/components/usage-format";
|
||||||
|
|
||||||
function priceLabel(cost: ModelCatalogEntry["cost"]): string {
|
function priceLabel(cost: ModelCatalogEntry["cost"]): string {
|
||||||
@@ -53,6 +55,7 @@ export function ModelMenuList({
|
|||||||
/** Quiet footer note under the list (context-specific). */
|
/** Quiet footer note under the list (context-specific). */
|
||||||
footer?: string;
|
footer?: string;
|
||||||
}) {
|
}) {
|
||||||
|
const { t, language } = useI18n();
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [menuIdx, setMenuIdx] = useState<number | null>(null);
|
const [menuIdx, setMenuIdx] = useState<number | null>(null);
|
||||||
const searchRef = useRef<HTMLInputElement>(null);
|
const searchRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -104,12 +107,12 @@ export function ModelMenuList({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="Search models…"
|
placeholder={t("Search models…")}
|
||||||
className="lh-input mb-1.5 w-full rounded-lg border bg-background px-2 py-1.5 text-foreground outline-none focus:ring-1 focus:ring-ring"
|
className="lh-input mb-1.5 w-full rounded-lg border bg-background px-2 py-1.5 text-foreground outline-none focus:ring-1 focus:ring-ring"
|
||||||
/>
|
/>
|
||||||
{!models && <div className="px-2 py-3 text-center text-muted-foreground">Loading models…</div>}
|
{!models && <div className="px-2 py-3 text-center text-muted-foreground">{t("Loading models…")}</div>}
|
||||||
{models && results.length === 0 && (
|
{models && results.length === 0 && (
|
||||||
<div className="px-2 py-3 text-center text-muted-foreground">No models match.</div>
|
<div className="px-2 py-3 text-center text-muted-foreground">{t("No models match.")}</div>
|
||||||
)}
|
)}
|
||||||
<ul ref={listRef} data-testid="model-list" className="flex max-h-72 flex-col overflow-y-auto px-1">
|
<ul ref={listRef} data-testid="model-list" className="flex max-h-72 flex-col overflow-y-auto px-1">
|
||||||
{results.map((m, i) => (
|
{results.map((m, i) => (
|
||||||
@@ -125,13 +128,13 @@ export function ModelMenuList({
|
|||||||
>
|
>
|
||||||
<span className="flex items-center justify-between gap-2">
|
<span className="flex items-center justify-between gap-2">
|
||||||
<span className="truncate text-card-foreground">{m.name}</span>
|
<span className="truncate text-card-foreground">{m.name}</span>
|
||||||
<span className="shrink-0 tabular-nums text-muted-foreground" title="USD per 1M tokens in/out">
|
<span className="shrink-0 tabular-nums text-muted-foreground" title={subscriptionLabel(`${m.provider}:${m.id}`) ? t("Subscription connection") : t("USD per 1M tokens in/out")}>
|
||||||
{priceLabel(m.cost)}
|
{subscriptionLabel(`${m.provider}:${m.id}`) ?? priceLabel(m.cost)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center justify-between gap-2 text-muted-foreground">
|
<span className="flex items-center justify-between gap-2 text-muted-foreground">
|
||||||
<span className="truncate">{m.provider} · {m.id}</span>
|
<span className="truncate">{subscriptionLabel(`${m.provider}:${m.id}`) ? t("{v0} (subscription)", { v0: subscriptionLabel(`${m.provider}:${m.id}`) }) : m.provider} · {m.id}</span>
|
||||||
<span className="shrink-0 tabular-nums">{formatTokens(m.contextWindow)}</span>
|
<span className="shrink-0 tabular-nums">{formatTokens(m.contextWindow, language)}</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
@@ -160,22 +163,23 @@ export function ThinkingMenuList({
|
|||||||
empty?: string;
|
empty?: string;
|
||||||
footer?: string;
|
footer?: string;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{levels.length === 0 ? (
|
{levels.length === 0 ? (
|
||||||
<div className="px-2 py-3 text-center text-muted-foreground">{empty ?? "No thinking levels."}</div>
|
<div className="px-2 py-3 text-center text-muted-foreground">{empty ?? t("No thinking levels.")}</div>
|
||||||
) : (
|
) : (
|
||||||
levels.map((level) => (
|
levels.map((level) => (
|
||||||
<button
|
<button
|
||||||
key={level}
|
key={t(level)}
|
||||||
type="button"
|
type="button"
|
||||||
data-testid={`thinking-option-${level}`}
|
data-testid={`thinking-option-${t(level)}`}
|
||||||
onClick={() => onPick(level)}
|
onClick={() => onPick(level)}
|
||||||
className={`flex items-center justify-between rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-muted ${
|
className={`flex items-center justify-between rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-muted ${
|
||||||
level === effectiveLevel ? "bg-muted/60 text-card-foreground" : "text-card-foreground"
|
level === effectiveLevel ? "bg-muted/60 text-card-foreground" : "text-card-foreground"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span>{level}</span>
|
<span>{t(level)}</span>
|
||||||
{level === effectiveLevel && <span aria-hidden>✓</span>}
|
{level === effectiveLevel && <span aria-hidden>✓</span>}
|
||||||
</button>
|
</button>
|
||||||
))
|
))
|
||||||
@@ -199,6 +203,7 @@ export function ProviderMenuList({
|
|||||||
providers: { id: string; envVar: string; label: string }[];
|
providers: { id: string; envVar: string; label: string }[];
|
||||||
onPick: (provider: { id: string; envVar: string; label: string }) => void;
|
onPick: (provider: { id: string; envVar: string; label: string }) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [menuIdx, setMenuIdx] = useState<number | null>(null);
|
const [menuIdx, setMenuIdx] = useState<number | null>(null);
|
||||||
const searchRef = useRef<HTMLInputElement>(null);
|
const searchRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -248,11 +253,11 @@ export function ProviderMenuList({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="Search providers…"
|
placeholder={t("Search providers…")}
|
||||||
className="lh-input mb-1.5 w-full rounded-lg border bg-background px-2 py-1.5 text-foreground outline-none focus:ring-1 focus:ring-ring"
|
className="lh-input mb-1.5 w-full rounded-lg border bg-background px-2 py-1.5 text-foreground outline-none focus:ring-1 focus:ring-ring"
|
||||||
/>
|
/>
|
||||||
{results.length === 0 && (
|
{results.length === 0 && (
|
||||||
<div className="px-2 py-3 text-center text-muted-foreground">No providers match.</div>
|
<div className="px-2 py-3 text-center text-muted-foreground">{t("No providers match.")}</div>
|
||||||
)}
|
)}
|
||||||
<ul ref={listRef} data-testid="provider-list" className="flex max-h-72 flex-col overflow-y-auto px-1">
|
<ul ref={listRef} data-testid="provider-list" className="flex max-h-72 flex-col overflow-y-auto px-1">
|
||||||
{results.map((p, i) => (
|
{results.map((p, i) => (
|
||||||
@@ -283,7 +288,7 @@ export function ProviderMenuList({
|
|||||||
* with the default first and a searched list drops it honestly — Enter
|
* with the default first and a searched list drops it honestly — Enter
|
||||||
* can never pick a row that isn't on screen. Pure — exported for
|
* can never pick a row that isn't on screen. Pure — exported for
|
||||||
* tests. */
|
* tests. */
|
||||||
export function fontMenuResults(query: string, families: string[]): Array<string | null> {
|
export function fontMenuResults(query: string, families: string[], defaultFamily = "Source Sans 3"): Array<string | null> {
|
||||||
const scored = families
|
const scored = families
|
||||||
.map((f) => ({ f, s: fuzzyScore(query, f) }))
|
.map((f) => ({ f, s: fuzzyScore(query, f) }))
|
||||||
.filter((r) => r.s !== null)
|
.filter((r) => r.s !== null)
|
||||||
@@ -291,7 +296,7 @@ export function fontMenuResults(query: string, families: string[]): Array<string
|
|||||||
.map((r) => r.f);
|
.map((r) => r.f);
|
||||||
// The default row's searchable identity: the factory family + the
|
// The default row's searchable identity: the factory family + the
|
||||||
// word "default" (the suffix it renders with).
|
// word "default" (the suffix it renders with).
|
||||||
const defaultScore = fuzzyScore(query, "Source Sans 3", "default");
|
const defaultScore = fuzzyScore(query, defaultFamily, "default");
|
||||||
if (query.trim() === "" || defaultScore !== null) return [null, ...scored];
|
if (query.trim() === "" || defaultScore !== null) return [null, ...scored];
|
||||||
return scored;
|
return scored;
|
||||||
}
|
}
|
||||||
@@ -300,10 +305,10 @@ export function fontMenuResults(query: string, families: string[]): Array<string
|
|||||||
* query that matches NO family becomes the "Use '<query>'" custom pick —
|
* query that matches NO family becomes the "Use '<query>'" custom pick —
|
||||||
* a matching family is the better pick, so the action row only exists
|
* a matching family is the better pick, so the action row only exists
|
||||||
* when there is nothing else to pick. Exported for tests. */
|
* when there is nothing else to pick. Exported for tests. */
|
||||||
export function customFamilyFor(query: string, families: string[]): string | null {
|
export function customFamilyFor(query: string, families: string[], defaultFamily = "Source Sans 3"): string | null {
|
||||||
const trimmed = query.trim();
|
const trimmed = query.trim();
|
||||||
if (trimmed === "") return null;
|
if (trimmed === "") return null;
|
||||||
return fontMenuResults(query, families).length === 0 ? trimmed : null;
|
return fontMenuResults(query, families, defaultFamily).length === 0 ? trimmed : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The reading-font dropdown body — ONE search field (owner ask
|
/** The reading-font dropdown body — ONE search field (owner ask
|
||||||
@@ -329,6 +334,7 @@ export function FontMenuList({
|
|||||||
/** Quiet footer note under the list. */
|
/** Quiet footer note under the list. */
|
||||||
footer?: string;
|
footer?: string;
|
||||||
}) {
|
}) {
|
||||||
|
const { t, language } = useI18n();
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [menuIdx, setMenuIdx] = useState<number | null>(null);
|
const [menuIdx, setMenuIdx] = useState<number | null>(null);
|
||||||
const searchRef = useRef<HTMLInputElement>(null);
|
const searchRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -343,10 +349,11 @@ export function FontMenuList({
|
|||||||
listRef.current?.querySelector(`[data-idx="${menuIdx}"]`)?.scrollIntoView({ block: "nearest" });
|
listRef.current?.querySelector(`[data-idx="${menuIdx}"]`)?.scrollIntoView({ block: "nearest" });
|
||||||
}, [menuIdx]);
|
}, [menuIdx]);
|
||||||
|
|
||||||
const results = fontMenuResults(query, families ?? []);
|
const defaultFamily = language === "ru" ? "Noto Sans" : "Source Sans 3";
|
||||||
|
const results = fontMenuResults(query, families ?? [], defaultFamily);
|
||||||
// The custom-family action row: only when a typed query matches
|
// The custom-family action row: only when a typed query matches
|
||||||
// nothing (a matching family is the better pick). Enter picks it.
|
// nothing (a matching family is the better pick). Enter picks it.
|
||||||
const customFamily = customFamilyFor(query, families ?? []);
|
const customFamily = customFamilyFor(query, families ?? [], defaultFamily);
|
||||||
const walkable: Array<string | null> = customFamily !== null ? ["__custom__"] : results;
|
const walkable: Array<string | null> = customFamily !== null ? ["__custom__"] : results;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -378,10 +385,10 @@ export function FontMenuList({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="Search fonts…"
|
placeholder={t("Search fonts…")}
|
||||||
className="lh-input mb-1.5 w-full rounded-lg border bg-background px-2 py-1.5 text-foreground outline-none focus:ring-1 focus:ring-ring"
|
className="lh-input mb-1.5 w-full rounded-lg border bg-background px-2 py-1.5 text-foreground outline-none focus:ring-1 focus:ring-ring"
|
||||||
/>
|
/>
|
||||||
{!families && <div className="px-2 py-3 text-center text-muted-foreground">Loading fonts…</div>}
|
{!families && <div className="px-2 py-3 text-center text-muted-foreground">{t("Loading fonts…")}</div>}
|
||||||
<ul ref={listRef} data-testid="font-list" className="flex max-h-72 flex-col overflow-y-auto px-1">
|
<ul ref={listRef} data-testid="font-list" className="flex max-h-72 flex-col overflow-y-auto px-1">
|
||||||
{families &&
|
{families &&
|
||||||
results.map((family, i) => {
|
results.map((family, i) => {
|
||||||
@@ -398,7 +405,7 @@ export function FontMenuList({
|
|||||||
i === menuIdx ? "bg-accent/40 ring-1 ring-inset ring-border" : ""
|
i === menuIdx ? "bg-accent/40 ring-1 ring-inset ring-border" : ""
|
||||||
}${effective && i !== menuIdx ? " bg-muted/60" : ""}`}
|
}${effective && i !== menuIdx ? " bg-muted/60" : ""}`}
|
||||||
>
|
>
|
||||||
<span className="truncate text-card-foreground">{family ?? "Source Sans 3"}</span>
|
<span className="truncate text-card-foreground">{family ?? (language === "ru" ? "Noto Sans" : "Source Sans 3")}</span>
|
||||||
<span className="shrink-0 text-muted-foreground">{isDefault ? "default" : effective && <span aria-hidden>✓</span>}</span>
|
<span className="shrink-0 text-muted-foreground">{isDefault ? "default" : effective && <span aria-hidden>✓</span>}</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
@@ -415,8 +422,8 @@ export function FontMenuList({
|
|||||||
menuIdx === 0 ? "bg-accent/40 ring-1 ring-inset ring-border" : ""
|
menuIdx === 0 ? "bg-accent/40 ring-1 ring-inset ring-border" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="truncate text-card-foreground">Use “{customFamily}”</span>
|
<span className="truncate text-card-foreground">{t("Use “")}{customFamily}”</span>
|
||||||
<span className="shrink-0 text-muted-foreground">custom</span>
|
<span className="shrink-0 text-muted-foreground">{t("custom")}</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
type GraphSettings,
|
type GraphSettings,
|
||||||
type UserSettings,
|
type UserSettings,
|
||||||
} from "@/src/core";
|
} from "@/src/core";
|
||||||
|
import { LanguageSelect, SttPreferredLanguages, useI18n } from "@/components/i18n";
|
||||||
import { useAppearance } from "./appearance.tsx";
|
import { useAppearance } from "./appearance.tsx";
|
||||||
import { SettingsSection, SettingsRow, Toggle } from "./controls.tsx";
|
import { SettingsSection, SettingsRow, Toggle } from "./controls.tsx";
|
||||||
import { AgentSection } from "./agent-section.tsx";
|
import { AgentSection } from "./agent-section.tsx";
|
||||||
@@ -49,17 +50,19 @@ import { KeysSection } from "./keys-section.tsx";
|
|||||||
import { Hint } from "@/components/ui/hint";
|
import { Hint } from "@/components/ui/hint";
|
||||||
import { AppearanceSection, GraphSection } from "./appearance-section.tsx";
|
import { AppearanceSection, GraphSection } from "./appearance-section.tsx";
|
||||||
import { KeyboardSection } from "./keyboard-section.tsx";
|
import { KeyboardSection } from "./keyboard-section.tsx";
|
||||||
|
import { ChatGPTUIProvider } from "./chatgpt-connection.tsx";
|
||||||
|
import { NativeSubscriptionUIProvider } from "./subscription-connections.tsx";
|
||||||
|
|
||||||
/** Owner (2026-09-20): hints earn their place — mechanics-explaining
|
/** Owner (2026-09-20): hints earn their place — mechanics-explaining
|
||||||
* prose is out; only the keyboard note stays (storage location is a
|
* prose is out; only the keyboard note stays (storage location is a
|
||||||
* real question users ask). */
|
* real question users ask). */
|
||||||
const SECTIONS = [
|
const SECTIONS = [
|
||||||
{ id: "general", label: "General" },
|
{ id: "keys", label: "Providers" },
|
||||||
{ id: "keys", label: "Keys" },
|
|
||||||
{ id: "agent", label: "Agent" },
|
{ id: "agent", label: "Agent" },
|
||||||
{ id: "appearance", label: "Appearance" },
|
{ id: "appearance", label: "Appearance" },
|
||||||
{ id: "graph", label: "Graph" },
|
{ id: "graph", label: "Graph" },
|
||||||
{ id: "keyboard", label: "Keyboard", hint: "Stored on this device" },
|
{ id: "keyboard", label: "Keyboard", hint: "Stored on this device" },
|
||||||
|
{ id: "general", label: "General" },
|
||||||
] as const;
|
] as const;
|
||||||
type SectionId = (typeof SECTIONS)[number]["id"];
|
type SectionId = (typeof SECTIONS)[number]["id"];
|
||||||
|
|
||||||
@@ -83,20 +86,20 @@ export function SettingsDialogProvider({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const [show, setShow] = useState(false);
|
const [show, setShow] = useState(false);
|
||||||
const [landOn, setLandOn] = useState<SectionId>("general");
|
const [landOn, setLandOn] = useState<SectionId>("keys");
|
||||||
const [landOnRow, setLandOnRow] = useState<string | null>(null);
|
const [landOnRow, setLandOnRow] = useState<string | null>(null);
|
||||||
const open = useCallback((section?: string, row?: string) => {
|
const open = useCallback((section?: string, row?: string) => {
|
||||||
if (section) setLandOn(section as SectionId);
|
setLandOn(section ? section as SectionId : "keys");
|
||||||
setLandOnRow(row ?? null);
|
setLandOnRow(row ?? null);
|
||||||
setShow(true);
|
setShow(true);
|
||||||
}, []);
|
}, []);
|
||||||
return (
|
return (
|
||||||
<DialogContext.Provider value={{ open }}>
|
<ChatGPTUIProvider><NativeSubscriptionUIProvider><DialogContext.Provider value={{ open }}>
|
||||||
{children}
|
{children}
|
||||||
{show && (
|
{show && (
|
||||||
<SettingsDialog onClose={() => setShow(false)} initialSection={landOn} initialRow={landOnRow} />
|
<SettingsDialog onClose={() => setShow(false)} initialSection={landOn} initialRow={landOnRow} />
|
||||||
)}
|
)}
|
||||||
</DialogContext.Provider>
|
</DialogContext.Provider></NativeSubscriptionUIProvider></ChatGPTUIProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,7 +136,7 @@ function SettingsDialog({
|
|||||||
* (portals render nothing under renderToStaticMarkup). */
|
* (portals render nothing under renderToStaticMarkup). */
|
||||||
export function SettingsDialogBody({
|
export function SettingsDialogBody({
|
||||||
onClose,
|
onClose,
|
||||||
initialSection = "general",
|
initialSection = "keys",
|
||||||
initialRow = null,
|
initialRow = null,
|
||||||
}: {
|
}: {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -142,6 +145,7 @@ export function SettingsDialogBody({
|
|||||||
* retargets follow it exactly like initialSection below. */
|
* retargets follow it exactly like initialSection below. */
|
||||||
initialRow?: string | null;
|
initialRow?: string | null;
|
||||||
}) {
|
}) {
|
||||||
|
const { t, general, saveGeneral } = useI18n();
|
||||||
const { onAppearanceChange } = useAppearance();
|
const { onAppearanceChange } = useAppearance();
|
||||||
const [record, setRecord] = useState<UserSettings | null | undefined>(
|
const [record, setRecord] = useState<UserSettings | null | undefined>(
|
||||||
undefined,
|
undefined,
|
||||||
@@ -230,6 +234,10 @@ export function SettingsDialogBody({
|
|||||||
body: object,
|
body: object,
|
||||||
) => {
|
) => {
|
||||||
setError(null);
|
setError(null);
|
||||||
|
if (section === "general") {
|
||||||
|
try { await saveGeneral(body); } catch (error) { setError(error instanceof Error ? error.message : "Could not save language settings"); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
const res = await fetch(`/api/settings/${section}`, {
|
const res = await fetch(`/api/settings/${section}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
@@ -258,30 +266,30 @@ export function SettingsDialogBody({
|
|||||||
if (section === "appearance")
|
if (section === "appearance")
|
||||||
onAppearanceChange(body as AppearanceSettings);
|
onAppearanceChange(body as AppearanceSettings);
|
||||||
},
|
},
|
||||||
[onAppearanceChange],
|
[onAppearanceChange, saveGeneral, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label="Settings"
|
aria-label={t("Settings")}
|
||||||
data-testid="settings-dialog"
|
data-testid="settings-dialog"
|
||||||
className="settings-dialog-pop flex h-[620px] max-h-[85vh] w-[920px] max-w-[90vw] flex-col overflow-hidden rounded-md border border-border bg-card shadow-xl"
|
className="settings-dialog-pop flex h-[620px] max-h-[85vh] w-[920px] max-w-[90vw] flex-col overflow-hidden rounded-md border border-border bg-card shadow-xl"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{/* Header — the app's quiet CMU title language. */}
|
{/* Header — the app's quiet CMU title language. */}
|
||||||
<header className="flex h-15 shrink-0 items-center gap-3 border-b border-border px-5">
|
<header className="flex h-15 shrink-0 items-center gap-3 border-b border-border px-5">
|
||||||
<h2 className="font-title text-2xl tracking-tight">Settings</h2>
|
<h2 className="font-title text-2xl tracking-tight">{t("Settings")}</h2>
|
||||||
<Hint
|
<Hint
|
||||||
// Esc closes the dialog (its own listener) — surface it.
|
// Esc closes the dialog (its own listener) — surface it.
|
||||||
label="Close"
|
label={t("Close")}
|
||||||
binding="escape"
|
binding="escape"
|
||||||
side="left"
|
side="left"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Close settings"
|
aria-label={t("Close settings")}
|
||||||
data-testid="settings-close"
|
data-testid="settings-close"
|
||||||
className="ml-auto size-7 rounded-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
className="ml-auto size-7 rounded-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
@@ -303,7 +311,7 @@ export function SettingsDialogBody({
|
|||||||
{/* Left nav — text-only, the session-list mono language,
|
{/* Left nav — text-only, the session-list mono language,
|
||||||
active = bg-accent. */}
|
active = bg-accent. */}
|
||||||
<nav
|
<nav
|
||||||
aria-label="Settings sections"
|
aria-label={t("Settings sections")}
|
||||||
data-testid="settings-nav"
|
data-testid="settings-nav"
|
||||||
className="flex w-44 shrink-0 flex-col gap-1 border-r border-border p-2"
|
className="flex w-44 shrink-0 flex-col gap-1 border-r border-border p-2"
|
||||||
>
|
>
|
||||||
@@ -320,7 +328,7 @@ export function SettingsDialogBody({
|
|||||||
}`}
|
}`}
|
||||||
onClick={() => setActive(s.id)}
|
onClick={() => setActive(s.id)}
|
||||||
>
|
>
|
||||||
{s.label}
|
{t(s.label)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
@@ -342,32 +350,35 @@ export function SettingsDialogBody({
|
|||||||
className="m-4 rounded-sm border border-red-500/40 px-3 py-2 text-xs text-red-500"
|
className="m-4 rounded-sm border border-red-500/40 px-3 py-2 text-xs text-red-500"
|
||||||
data-testid="settings-error"
|
data-testid="settings-error"
|
||||||
>
|
>
|
||||||
{error}
|
{t(error)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{record === undefined ? (
|
{record === undefined ? (
|
||||||
<p className="px-6 py-5 text-xs text-muted-foreground/70">
|
<p className="px-6 py-5 text-xs text-muted-foreground/70">
|
||||||
Loading settings…
|
{t("Loading settings…")}</p>
|
||||||
</p>
|
|
||||||
) : record === null ? (
|
) : record === null ? (
|
||||||
<p className="px-6 py-5 text-xs text-muted-foreground/70">
|
<p className="px-6 py-5 text-xs text-muted-foreground/70">
|
||||||
Settings could not be loaded — refresh to retry.
|
{t("Settings could not be loaded — refresh to retry.")}</p>
|
||||||
</p>
|
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
{active === "general" && (
|
{active === "general" && (
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
title="General"
|
title={t("General")}
|
||||||
testid="section-general"
|
testid="section-general"
|
||||||
>
|
>
|
||||||
|
<SettingsRow label={t("Interface language")} testid="row-language"><LanguageSelect field="language" /></SettingsRow>
|
||||||
|
<SettingsRow label={t("Speech recognition language")} testid="row-stt-language"><LanguageSelect field="sttLanguage" /></SettingsRow>
|
||||||
|
<SettingsRow label={t("Preferred languages")} hint={t("Used with Multilingual. Favors selected languages; other languages may still be recognized.")} testid="row-stt-preferred-languages" className="flex-col items-start gap-2">
|
||||||
|
<SttPreferredLanguages />
|
||||||
|
</SettingsRow>
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
label="Auto-update"
|
label={t("Auto-update")}
|
||||||
hint={AUTO_UPDATE_HINT}
|
hint={AUTO_UPDATE_HINT}
|
||||||
testid="row-auto-update"
|
testid="row-auto-update"
|
||||||
>
|
>
|
||||||
<Toggle
|
<Toggle
|
||||||
checked={record.general?.autoUpdate ?? true}
|
checked={general.autoUpdate ?? true}
|
||||||
label="auto update"
|
label={t("auto update")}
|
||||||
onChange={(on) =>
|
onChange={(on) =>
|
||||||
void write("general", { autoUpdate: on })
|
void write("general", { autoUpdate: on })
|
||||||
}
|
}
|
||||||
@@ -376,13 +387,13 @@ export function SettingsDialogBody({
|
|||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
)}
|
)}
|
||||||
{active === "keys" && (
|
{active === "keys" && (
|
||||||
<SettingsSection title="Keys" testid="section-keys">
|
<SettingsSection title={t("Providers")} testid="section-keys">
|
||||||
<KeysSection initialRow={initialRow ?? undefined} />
|
<KeysSection initialRow={initialRow ?? undefined} />
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
)}
|
)}
|
||||||
{active === "agent" && (
|
{active === "agent" && (
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
title="Agent"
|
title={t("Agent")}
|
||||||
testid="section-agent"
|
testid="section-agent"
|
||||||
>
|
>
|
||||||
<AgentSection
|
<AgentSection
|
||||||
@@ -391,7 +402,7 @@ export function SettingsDialogBody({
|
|||||||
graph={record.graph}
|
graph={record.graph}
|
||||||
compaction={record.compaction}
|
compaction={record.compaction}
|
||||||
onModelsChange={(next: ModelsSettings) =>
|
onModelsChange={(next: ModelsSettings) =>
|
||||||
void write("models", next)
|
write("models", next)
|
||||||
}
|
}
|
||||||
onSectionChange={(next: AgentSettings) =>
|
onSectionChange={(next: AgentSettings) =>
|
||||||
void write("agent", next)
|
void write("agent", next)
|
||||||
@@ -404,7 +415,7 @@ export function SettingsDialogBody({
|
|||||||
)}
|
)}
|
||||||
{active === "appearance" && (
|
{active === "appearance" && (
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
title="Appearance"
|
title={t("Appearance")}
|
||||||
testid="section-appearance"
|
testid="section-appearance"
|
||||||
>
|
>
|
||||||
<AppearanceSection
|
<AppearanceSection
|
||||||
@@ -417,7 +428,7 @@ export function SettingsDialogBody({
|
|||||||
)}
|
)}
|
||||||
{active === "graph" && (
|
{active === "graph" && (
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
title="Graph"
|
title={t("Graph")}
|
||||||
testid="section-graph"
|
testid="section-graph"
|
||||||
>
|
>
|
||||||
<GraphSection
|
<GraphSection
|
||||||
@@ -430,8 +441,8 @@ export function SettingsDialogBody({
|
|||||||
)}
|
)}
|
||||||
{active === "keyboard" && (
|
{active === "keyboard" && (
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
title="Keyboard"
|
title={t("Keyboard")}
|
||||||
hint="Stored on this device"
|
hint={t("Stored on this device")}
|
||||||
testid="section-keyboard"
|
testid="section-keyboard"
|
||||||
>
|
>
|
||||||
<KeyboardSection />
|
<KeyboardSection />
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
"use client";
|
||||||
|
import { translator, type Translator } from "@/src/core";
|
||||||
|
import { useI18n } from "@/components/i18n";
|
||||||
|
|
||||||
|
import { createContext, useContext, useEffect, useRef, useState } from "react";
|
||||||
|
import { NATIVE_SUBSCRIPTIONS, type NativeSubscriptionProviderId, type NativeSubscriptionStatus } from "@/src/core";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { notifySettingsSaved } from "@/components/settings-signal";
|
||||||
|
import { ConnectionDialog, ConnectionFooter, ConnectionRow } from "./connection-controls";
|
||||||
|
import { ChatGPTConnection } from "./chatgpt-connection";
|
||||||
|
import { Toggle } from "./controls";
|
||||||
|
|
||||||
|
export const COPILOT_POLICY_NOTICE = "Pi's native Copilot sign-in may enable additional models whose policy is not yet configured. Models explicitly disabled by policy are not enabled. Organization restrictions still apply.";
|
||||||
|
export const SUBSCRIPTION_BACKGROUND_HINT = "Allow LearnHarness helper agents and automatic follow-ups to use this connection.";
|
||||||
|
export function subscriptionNotice(label: string, t: Translator = translator("en")): string { return t("Your prompts, tool results, and relevant session context are sent to {v0} to run the work you select. Credentials are stored locally in an owner-only plaintext file. Provider terms, data-use policies, and plan limits apply. This connection never falls back to an API key.", { v0: label }); }
|
||||||
|
export function subscriptionDisconnectCopy(label: string, t: Translator = translator("en")): string { return t("Disconnect {v0} from LearnHarness? This stops work using this connection and removes its saved credentials from this device. It does not cancel your subscription or revoke access at the provider. Saved model choices will remain unavailable until you reconnect or change them.", { v0: label }); }
|
||||||
|
export function emptySubscriptionStatus(providerId: NativeSubscriptionProviderId): NativeSubscriptionStatus { return { providerId, available: false, connected: false, pending: false, allowBackgroundUse: true, connectionId: null, epoch: 0, authorityRevision: 0, revision: 0 }; }
|
||||||
|
const Context = createContext({ statuses: [] as NativeSubscriptionStatus[], busy: false, open: (_providerId: NativeSubscriptionProviderId) => {} });
|
||||||
|
const CONTROL_ERROR = "The connection could not be changed. Try again.";
|
||||||
|
|
||||||
|
export function NativeSubscriptionUIProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [statuses, setStatuses] = useState<NativeSubscriptionStatus[]>([]);
|
||||||
|
const [dialog, setDialog] = useState<NativeSubscriptionProviderId | null>(null);
|
||||||
|
const [view, setView] = useState<"notice" | "connection" | "manage">("notice");
|
||||||
|
const [confirm, setConfirm] = useState(false), [background, setBackground] = useState(true), [backgroundDirty, setBackgroundDirty] = useState(false), [promptValue, setPromptValue] = useState("");
|
||||||
|
const [operation, setOperation] = useState<"login" | "control" | null>(null), [error, setError] = useState<string | null>(null);
|
||||||
|
const mounted = useRef(false), sequence = useRef(0), generation = useRef(0), statusesRef = useRef(statuses);
|
||||||
|
const descriptor = NATIVE_SUBSCRIPTIONS.find((p) => p.providerId === dialog);
|
||||||
|
const status = statuses.find((s) => s.providerId === dialog) ?? (dialog ? emptySubscriptionStatus(dialog) : undefined);
|
||||||
|
const interaction = status?.interaction, prompt = interaction?.prompt;
|
||||||
|
const management = view === "manage";
|
||||||
|
const busy = operation === "control" || status?.committing === true;
|
||||||
|
async function refresh() {
|
||||||
|
const request = ++sequence.current;
|
||||||
|
try {
|
||||||
|
const values = await window.lh?.subscriptions?.status();
|
||||||
|
if (!mounted.current || request !== sequence.current || !Array.isArray(values)) return;
|
||||||
|
const accepted = NATIVE_SUBSCRIPTIONS.map(({ providerId }) => {
|
||||||
|
const next = values.find((s) => s.providerId === providerId), old = statusesRef.current.find((s) => s.providerId === providerId);
|
||||||
|
return next && (!old || next.revision >= old.revision) ? next : old ?? emptySubscriptionStatus(providerId);
|
||||||
|
});
|
||||||
|
statusesRef.current = accepted; setStatuses(accepted);
|
||||||
|
} catch { if (mounted.current && request === sequence.current) setError("The desktop connection is unavailable. Retry when the app is ready."); }
|
||||||
|
}
|
||||||
|
useEffect(() => {
|
||||||
|
mounted.current = true; void refresh();
|
||||||
|
const off = window.lh?.subscriptions?.onChange(() => { void refresh(); notifySettingsSaved("keys"); });
|
||||||
|
return () => { mounted.current = false; generation.current++; off?.(); };
|
||||||
|
// The root-lifetime subscriber reads current state through refs.
|
||||||
|
}, []);
|
||||||
|
useEffect(() => { setPromptValue(""); }, [prompt?.id]);
|
||||||
|
function open(providerId: NativeSubscriptionProviderId) {
|
||||||
|
if (operation || statusesRef.current.some((s) => s.pending)) return;
|
||||||
|
generation.current++; setError(null); setConfirm(false); setBackgroundDirty(false);
|
||||||
|
const current = statusesRef.current.find((s) => s.providerId === providerId);
|
||||||
|
setBackground(current?.allowBackgroundUse ?? true); setView(current?.connectionId ? "manage" : "notice"); setDialog(providerId);
|
||||||
|
}
|
||||||
|
async function control(action: () => Promise<void>, close = false) {
|
||||||
|
if (busy) return;
|
||||||
|
const owner = ++generation.current; setOperation("control"); setError(null);
|
||||||
|
try { await action(); await refresh(); if (mounted.current && owner === generation.current && close) setDialog(null); }
|
||||||
|
catch { if (mounted.current && owner === generation.current) setError(CONTROL_ERROR); }
|
||||||
|
finally { if (mounted.current && owner === generation.current) setOperation(null); }
|
||||||
|
}
|
||||||
|
function close() {
|
||||||
|
if (!dialog || busy) return;
|
||||||
|
if (confirm) { setConfirm(false); setError(null); return; }
|
||||||
|
const bridge = window.lh?.subscriptions;
|
||||||
|
if ((status?.pending || operation === "login") && bridge) { void control(() => bridge.cancel(dialog), true); return; }
|
||||||
|
generation.current++; setDialog(null); setError(null);
|
||||||
|
}
|
||||||
|
function beginSignIn() {
|
||||||
|
if (operation || busy || !status) return;
|
||||||
|
generation.current++; setError(null); setConfirm(false); setBackground(status.allowBackgroundUse); setBackgroundDirty(false); setView("notice");
|
||||||
|
}
|
||||||
|
async function login() {
|
||||||
|
const bridge = window.lh?.subscriptions;
|
||||||
|
if (!dialog || !bridge || operation || !status?.available) return;
|
||||||
|
const owner = ++generation.current; setOperation("login"); setError(null);
|
||||||
|
try { await bridge.login(dialog, backgroundDirty ? background : undefined); await refresh(); if (mounted.current && generation.current === owner) setDialog(null); }
|
||||||
|
catch { if (mounted.current && generation.current === owner) setError("Sign-in did not complete. Try connecting again."); }
|
||||||
|
finally { if (mounted.current && generation.current === owner) setOperation(null); }
|
||||||
|
}
|
||||||
|
const bridge = typeof window === "undefined" ? undefined : window.lh?.subscriptions;
|
||||||
|
return <Context.Provider value={{ statuses, busy: !!operation, open }}>
|
||||||
|
{children}
|
||||||
|
{dialog && descriptor && status && <ConnectionDialog key={`${dialog}:${view}:${confirm}`} label={t("{v0} connection", { v0: descriptor.label })} testid="subscription-connection-dialog" backdropTestid="subscription-dialog-backdrop" busy={busy} onClose={close}>
|
||||||
|
<h2 className="text-sm font-semibold text-foreground">{descriptor.label}</h2>
|
||||||
|
{confirm ? <>
|
||||||
|
<p className="mt-3 text-sm font-semibold text-red-400">{t("Disconnect")}{" "}{descriptor.label}?</p>
|
||||||
|
<p className="mt-3 text-xs leading-relaxed text-muted-foreground">{subscriptionDisconnectCopy(descriptor.label, t)}</p>
|
||||||
|
</> : view === "notice" ? <>
|
||||||
|
<p className="mt-3 text-xs leading-relaxed text-muted-foreground" data-testid="subscription-privacy-notice">{subscriptionNotice(descriptor.label, t)}</p>
|
||||||
|
{dialog === "github-copilot" && <p className="mt-3 text-xs leading-relaxed text-muted-foreground" data-testid="copilot-policy-notice">{t(COPILOT_POLICY_NOTICE)}</p>}
|
||||||
|
</> : <>
|
||||||
|
<div className="mt-4 flex items-center gap-3 text-xs text-foreground">
|
||||||
|
<Toggle label={t("Allow background use")} checked={management ? status.allowBackgroundUse : background} disabled={busy || !!operation || status.pending || !status.available} onChange={(enabled) => { setBackground(enabled); if (management && bridge) void control(() => bridge.consent(dialog, enabled)); else setBackgroundDirty(true); }} />
|
||||||
|
<span>{t("Allow background use")}<span className="mt-1 block leading-relaxed text-muted-foreground">{t(SUBSCRIPTION_BACKGROUND_HINT)}</span></span>
|
||||||
|
</div>
|
||||||
|
{!status.available && <p className="mt-3 text-xs text-muted-foreground">{t("Subscription sign-in requires the desktop app's private server connection. API-key connections remain available here.")}</p>}
|
||||||
|
{view === "connection" && interaction && <div className="mt-3 space-y-3 text-xs" data-testid="subscription-login-interaction">
|
||||||
|
{interaction.progress && <p role="status" className="text-muted-foreground">{t(interaction.progress)}</p>}
|
||||||
|
{interaction.userCode && <p>{t("Device code:")}{" "}<code className="select-text font-mono">{interaction.userCode}</code></p>}
|
||||||
|
{interaction.browser && <Button variant="outline" size="sm" disabled={busy} onClick={() => bridge && void bridge.open(dialog, interaction.attemptId, interaction.browser!.id).catch(() => setError("The browser could not be opened. Try again."))}>{t("Open browser")}</Button>}
|
||||||
|
{prompt && <form className="space-y-2" onSubmit={(event) => { event.preventDefault(); if (bridge) void bridge.respond(dialog, interaction.attemptId, prompt.id, promptValue).catch(() => setError(CONTROL_ERROR)); }}>
|
||||||
|
<label className="block" htmlFor="subscription-auth-prompt">{t(prompt.message)}</label>
|
||||||
|
{prompt.type === "select" ? <select id="subscription-auth-prompt" value={promptValue} onChange={(event) => setPromptValue(event.target.value)} className="w-full rounded-sm border border-border bg-background px-2 py-1.5"><option value="" disabled>{t("Select…")}</option>{prompt.options?.map((option) => <option key={option.id} value={option.id}>{t(option.label)}</option>)}</select> : <input id="subscription-auth-prompt" type={prompt.type === "secret" ? "password" : "text"} autoComplete="off" value={promptValue} placeholder={prompt.placeholder ? t(prompt.placeholder) : undefined} onChange={(event) => setPromptValue(event.target.value)} className="w-full rounded-sm border border-border bg-background px-2 py-1.5" />}
|
||||||
|
<Button type="submit" variant="outline" size="sm" disabled={busy || (prompt.type === "select" && !promptValue)}>{t("Continue")}</Button>
|
||||||
|
</form>}
|
||||||
|
</div>}
|
||||||
|
</>}
|
||||||
|
{(error || status.error) && <p role="alert" className="mt-3 text-xs text-red-400">{t(error || status.error || "")}</p>}
|
||||||
|
<ConnectionFooter testid="subscription-connection-footer" leading={!confirm && management && status.connectionId && !status.pending && <Button variant="ghost" size="sm" className="cursor-pointer text-red-400 hover:bg-red-500/10 hover:text-red-300" disabled={busy || !!operation} onClick={() => setConfirm(true)}>{t("Disconnect")}</Button>}>
|
||||||
|
{!confirm && management && status.connectionId && status.available && !status.connected && !!status.error && !status.pending && <>
|
||||||
|
{status.error && <Button variant="outline" size="sm" disabled={busy || !!operation} onClick={() => bridge && void control(() => bridge.retry(dialog))}>{t("Retry")}</Button>}
|
||||||
|
<Button variant="outline" size="sm" disabled={!status.available || busy || !!operation} onClick={beginSignIn}>{t("Sign in again")}</Button>
|
||||||
|
</>}
|
||||||
|
<Button variant={!confirm && management ? "outline" : "ghost"} size="sm" disabled={busy} onClick={close}>{confirm || !management ? t("Cancel") : t("Close")}</Button>
|
||||||
|
{confirm ? <Button variant="destructive" size="sm" disabled={busy} onClick={() => bridge && void control(() => bridge.disconnect(dialog), true)}>{t("Disconnect")}</Button> : view === "notice" ? <Button variant="outline" size="sm" disabled={!status.available || !!operation} onClick={() => setView("connection")}>{t("Continue")}</Button> : view === "connection" && <Button variant="outline" size="sm" disabled={!status.available || !!operation || status.pending} onClick={() => void login()}>{t("Connect")}</Button>}
|
||||||
|
</ConnectionFooter>
|
||||||
|
</ConnectionDialog>}
|
||||||
|
</Context.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NativeSubscriptionConnection({ providerId }: { providerId: NativeSubscriptionProviderId }) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const ui = useContext(Context), descriptor = NATIVE_SUBSCRIPTIONS.find((p) => p.providerId === providerId)!;
|
||||||
|
const status = ui.statuses.find((s) => s.providerId === providerId) ?? emptySubscriptionStatus(providerId);
|
||||||
|
return <div className="flex flex-col gap-2" data-testid={`${providerId}-connection`}>
|
||||||
|
<ConnectionRow label={descriptor.label} action={status.connectionId ? t("Manage…") : t("Connect")} disabled={ui.busy || !status.available || ui.statuses.some((s) => s.pending)} onAction={() => ui.open(providerId)} />
|
||||||
|
{status.error && <p role="alert" className="text-xs text-red-400">{t(status.error)}</p>}
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
export function SubscriptionConnections({ setup = false }: { setup?: boolean }) {
|
||||||
|
return <div className="space-y-3" data-testid="subscription-connections"><ChatGPTConnection setup={setup} />{NATIVE_SUBSCRIPTIONS.map((p) => <NativeSubscriptionConnection key={p.providerId} providerId={p.providerId} />)}</div>;
|
||||||
|
}
|
||||||
|
export function NativeSubscriptionBackgroundAccess({ provider }: { provider: string | undefined }) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const ui = useContext(Context), descriptor = NATIVE_SUBSCRIPTIONS.find((p) => p.logicalId === provider);
|
||||||
|
const status = ui.statuses.find((s) => s.providerId === descriptor?.providerId);
|
||||||
|
if (!descriptor || !status?.connected || status.allowBackgroundUse) return null;
|
||||||
|
return <Button type="button" size="xs" variant="ghost" disabled={ui.busy} onClick={() => ui.open(descriptor.providerId)}>{t("Enable background use")}</Button>;
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { useI18n } from "@/components/i18n";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The zero-keys surfaces (zero-keys wave, 2026-09-25, owner-locked over
|
* The zero-keys surfaces (zero-keys wave, 2026-09-25, owner-locked over
|
||||||
* Platform's 66dbea9 key-presence gate): when NO model-provider key
|
* Platform's 66dbea9 key-presence gate): when NO model-provider key
|
||||||
@@ -15,12 +17,10 @@
|
|||||||
* its own save), the same re-read pattern chat.tsx uses for "models".
|
* its own save), the same re-read pattern chat.tsx uses for "models".
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
import type { AuthKeyStatus } from "@/src/core";
|
import type { AuthKeyStatus } from "@/src/core";
|
||||||
import { classifyKeys, type ProviderKeyInfo } from "@/components/settings/keys-section";
|
import { classifyKeys, type ProviderKeyInfo } from "@/components/settings/keys-section";
|
||||||
import { useModelCatalog } from "@/components/model-picker";
|
import { useModelCatalog } from "@/components/model-picker";
|
||||||
import type { ModelCatalogResponse } from "@/src/core";
|
import type { ModelCatalogResponse } from "@/src/core";
|
||||||
import { useSettingsSaved } from "@/components/settings-signal";
|
|
||||||
|
|
||||||
/** Pure: does a MODEL-PROVIDER key exist among the statuses? Reuses
|
/** Pure: does a MODEL-PROVIDER key exist among the statuses? Reuses
|
||||||
* classifyKeys (the keys section's own classification — no drift):
|
* classifyKeys (the keys section's own classification — no drift):
|
||||||
@@ -40,28 +40,15 @@ export function hasProviderKey(
|
|||||||
* failed read claiming "zero keys" would be a lie the CTA flow then
|
* failed read claiming "zero keys" would be a lie the CTA flow then
|
||||||
* leans on). Re-reads on the keys signal. */
|
* leans on). Re-reads on the keys signal. */
|
||||||
export function useZeroKeys(): boolean {
|
export function useZeroKeys(): boolean {
|
||||||
const [zeroKeys, setZeroKeys] = useState(false);
|
const { catalog } = useModelCatalog();
|
||||||
const read = useCallback(() => {
|
return catalog ? !catalog.providers.some((provider) => provider.keyPresent) : false;
|
||||||
fetch("/api/keys")
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((body: { keys?: AuthKeyStatus[]; providers?: ProviderKeyInfo[] }) => {
|
|
||||||
setZeroKeys(!hasProviderKey(body.keys ?? [], body.providers));
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
}, []);
|
|
||||||
useEffect(() => {
|
|
||||||
read();
|
|
||||||
}, [read]);
|
|
||||||
useSettingsSaved((section) => {
|
|
||||||
if (section === "keys") read();
|
|
||||||
});
|
|
||||||
return zeroKeys;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The pill itself — the models CTA's chrome and placement, the keys
|
/** The pill itself — the models CTA's chrome and placement, the keys
|
||||||
* chain's copy. testid "setup-cta-keys" (the models CTA keeps
|
* chain's copy. testid "setup-cta-keys" (the models CTA keeps
|
||||||
* "setup-cta"; probes can tell them apart). */
|
* "setup-cta"; probes can tell them apart). */
|
||||||
export function ZeroKeysPill({ onOpen, inline = false }: { onOpen: () => void; inline?: boolean }) {
|
export function ZeroKeysPill({ onOpen, inline = false }: { onOpen: () => void; inline?: boolean }) {
|
||||||
|
const { t } = useI18n();
|
||||||
const button = (
|
const button = (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -69,8 +56,7 @@ export function ZeroKeysPill({ onOpen, inline = false }: { onOpen: () => void; i
|
|||||||
onClick={onOpen}
|
onClick={onOpen}
|
||||||
className="rounded-sm border border-border bg-card px-3 py-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50 hover:bg-accent cursor-pointer"
|
className="rounded-sm border border-border bg-card px-3 py-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50 hover:bg-accent cursor-pointer"
|
||||||
>
|
>
|
||||||
Add a provider key
|
{t("Add a provider key")}</button>
|
||||||
</button>
|
|
||||||
);
|
);
|
||||||
return inline ? button : <div className="pointer-events-auto mb-2 flex justify-center">{button}</div>;
|
return inline ? button : <div className="pointer-events-auto mb-2 flex justify-center">{button}</div>;
|
||||||
}
|
}
|
||||||
@@ -93,9 +79,6 @@ export function strongKeyless(catalog: ModelCatalogResponse | undefined): boolea
|
|||||||
* (a provider key saved or deleted) and "models" (the strong slot
|
* (a provider key saved or deleted) and "models" (the strong slot
|
||||||
* re-pinned). Live-updates with the page, never mid-send. */
|
* re-pinned). Live-updates with the page, never mid-send. */
|
||||||
export function useStrongKeyless(): boolean {
|
export function useStrongKeyless(): boolean {
|
||||||
const { catalog, refresh } = useModelCatalog();
|
const { catalog } = useModelCatalog();
|
||||||
useSettingsSaved((section) => {
|
|
||||||
if (section === "keys" || section === "models") refresh();
|
|
||||||
});
|
|
||||||
return strongKeyless(catalog);
|
return strongKeyless(catalog);
|
||||||
}
|
}
|
||||||
|
|||||||
+39
-32
@@ -1,4 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { useI18n } from "@/components/i18n";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The first-run setup flow (model-slots wave, 2026-09-20, owner design
|
* The first-run setup flow (model-slots wave, 2026-09-20, owner design
|
||||||
* "A — composer CTA + send interception"): chats cannot start until the
|
* "A — composer CTA + send interception"): chats cannot start until the
|
||||||
@@ -44,6 +46,8 @@ import { notifySettingsSaved, useSettingsSaved } from "@/components/settings-sig
|
|||||||
import { Hint } from "@/components/ui/hint";
|
import { Hint } from "@/components/ui/hint";
|
||||||
import { type ProviderKeyInfo } from "@/components/settings/keys-section";
|
import { type ProviderKeyInfo } from "@/components/settings/keys-section";
|
||||||
import { ProviderMenuList } from "@/components/settings/model-menu";
|
import { ProviderMenuList } from "@/components/settings/model-menu";
|
||||||
|
import { ChatGPTBackgroundAccess } from "@/components/settings/chatgpt-connection";
|
||||||
|
import { NativeSubscriptionBackgroundAccess, SubscriptionConnections } from "@/components/settings/subscription-connections";
|
||||||
import { defaultOrderLevel, populateLevel, THINKING_LABEL } from "@/components/thinking-default";
|
import { defaultOrderLevel, populateLevel, THINKING_LABEL } from "@/components/thinking-default";
|
||||||
import { ThinkingMenuList } from "@/components/settings/model-menu";
|
import { ThinkingMenuList } from "@/components/settings/model-menu";
|
||||||
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
||||||
@@ -145,23 +149,22 @@ export function SetupKeysBody({
|
|||||||
/** The MenuShell anchor — wired by the wrapper (its triggerRef). */
|
/** The MenuShell anchor — wired by the wrapper (its triggerRef). */
|
||||||
menuAnchor: React.RefObject<HTMLDivElement | null>;
|
menuAnchor: React.RefObject<HTMLDivElement | null>;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3 px-6 py-5" data-testid="setup-stage-keys">
|
<div className="flex flex-col gap-3 px-6 py-5" data-testid="setup-stage-keys">
|
||||||
<p className="text-sm text-foreground">Add a provider key.</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Choose your AI provider and paste its API key. You’ll pick your models next.
|
{t("Choose your AI provider and paste its API key. You’ll pick your models next.")}</p>
|
||||||
</p>
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<div ref={menuAnchor} className="relative max-w-96">
|
<div ref={menuAnchor} className="relative max-w-96">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Provider"
|
aria-label={t("Provider")}
|
||||||
data-testid="setup-key-provider"
|
data-testid="setup-key-provider"
|
||||||
aria-expanded={menuOpen}
|
aria-expanded={menuOpen}
|
||||||
onClick={onToggleMenu}
|
onClick={onToggleMenu}
|
||||||
className="flex h-7 w-full items-center gap-1 rounded-sm border border-border bg-background px-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50"
|
className="flex h-7 w-full items-center gap-1 rounded-sm border border-border bg-background px-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50"
|
||||||
>
|
>
|
||||||
<span className="min-w-0 flex-1 truncate text-left">{provider ? provider.label : "Pick a provider…"}</span>
|
<span className="min-w-0 flex-1 truncate text-left">{provider ? provider.label : t("Pick a provider…")}</span>
|
||||||
<span aria-hidden className="text-muted-foreground">
|
<span aria-hidden className="text-muted-foreground">
|
||||||
▾
|
▾
|
||||||
</span>
|
</span>
|
||||||
@@ -187,8 +190,8 @@ export function SetupKeysBody({
|
|||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" && value.trim()) onSave();
|
if (e.key === "Enter" && value.trim()) onSave();
|
||||||
}}
|
}}
|
||||||
placeholder="Paste key…"
|
placeholder={t("Paste key…")}
|
||||||
aria-label={`${provider.label} key`}
|
aria-label={t("{v0} key", { v0: provider.label })}
|
||||||
data-testid="setup-key-input"
|
data-testid="setup-key-input"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
autoFocus
|
autoFocus
|
||||||
@@ -201,16 +204,17 @@ export function SetupKeysBody({
|
|||||||
data-testid="setup-key-save"
|
data-testid="setup-key-save"
|
||||||
className="shrink-0 rounded-sm border border-border bg-background px-3 py-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50 disabled:cursor-not-allowed disabled:opacity-40"
|
className="shrink-0 rounded-sm border border-border bg-background px-3 py-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
>
|
>
|
||||||
{saving ? "Saving…" : "Save key"}
|
{saving ? t("Saving…") : t("Save key")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{error && (
|
{error && (
|
||||||
<p role="alert" data-testid="setup-key-error" className="text-xs text-red-400">
|
<p role="alert" data-testid="setup-key-error" className="text-xs text-red-400">
|
||||||
{error}
|
{t(error)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-2 border-t border-border/60 pt-4"><h3 className="mb-3 font-ui text-xs text-muted-foreground">{t("Subscriptions")}</h3><SubscriptionConnections setup /></div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -226,6 +230,7 @@ export function SetupDialog({
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onDone: (models: ModelsSettings) => void;
|
onDone: (models: ModelsSettings) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
const backdropDismiss = useBackdropDismiss(onClose);
|
const backdropDismiss = useBackdropDismiss(onClose);
|
||||||
const [body, setBody] = useState<ModelsBody | null>(null);
|
const [body, setBody] = useState<ModelsBody | null>(null);
|
||||||
const [failed, setFailed] = useState(false);
|
const [failed, setFailed] = useState(false);
|
||||||
@@ -265,16 +270,19 @@ export function SetupDialog({
|
|||||||
// moment ago (stage 1's own screen) widens the pickers immediately.
|
// moment ago (stage 1's own screen) widens the pickers immediately.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
|
let cancelled = false;
|
||||||
|
setBody(null);
|
||||||
setPicked({});
|
setPicked({});
|
||||||
setLevels({});
|
setLevels({});
|
||||||
setError(null);
|
setError(null);
|
||||||
fetch("/api/keys")
|
fetch("/api/keys")
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((b: { providers?: ProviderKeyInfo[] }) => setKeyProviders(b.providers ?? []))
|
.then((b: { providers?: ProviderKeyInfo[] }) => { if (!cancelled) setKeyProviders(b.providers ?? []); })
|
||||||
.catch(() => setKeyProviders([]));
|
.catch(() => { if (!cancelled) setKeyProviders([]); });
|
||||||
fetch("/api/models")
|
fetch("/api/models")
|
||||||
.then((r) => r.json())
|
.then((r) => { if (!r.ok) throw new Error("Catalog unavailable"); return r.json(); })
|
||||||
.then((b: ModelsBody) => {
|
.then((b: ModelsBody) => {
|
||||||
|
if (cancelled) return;
|
||||||
setBody(b);
|
setBody(b);
|
||||||
setFailed(false);
|
setFailed(false);
|
||||||
// Prefill (owner, 2026-09-22): each pinned tier opens with its
|
// Prefill (owner, 2026-09-22): each pinned tier opens with its
|
||||||
@@ -292,7 +300,8 @@ export function SetupDialog({
|
|||||||
}
|
}
|
||||||
setLevels(preLevels);
|
setLevels(preLevels);
|
||||||
})
|
})
|
||||||
.catch(() => setFailed(true));
|
.catch(() => { if (!cancelled) setFailed(true); });
|
||||||
|
return () => { cancelled = true; };
|
||||||
}, [open, keysTick]);
|
}, [open, keysTick]);
|
||||||
|
|
||||||
// Esc + outside-click — layered dismissal, closable WITHOUT
|
// Esc + outside-click — layered dismissal, closable WITHOUT
|
||||||
@@ -401,17 +410,17 @@ export function SetupDialog({
|
|||||||
<div
|
<div
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label="Set up your models"
|
aria-label={t("Set up your models")}
|
||||||
data-testid="setup-dialog"
|
data-testid="setup-dialog"
|
||||||
className="settings-dialog-pop flex max-h-[85vh] w-[640px] max-w-[90vw] flex-col overflow-hidden rounded-md border border-border bg-card shadow-xl"
|
className="settings-dialog-pop flex max-h-[85vh] w-[640px] max-w-[90vw] flex-col overflow-hidden rounded-md border border-border bg-card shadow-xl"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<header className="flex h-15 shrink-0 items-center gap-3 border-b border-border px-5">
|
<header className="flex h-15 shrink-0 items-center gap-3 border-b border-border px-5">
|
||||||
<h2 className="font-title text-2xl tracking-tight">Set up your models</h2>
|
<h2 className="font-title text-2xl tracking-tight">{t("Set up your models")}</h2>
|
||||||
<Hint label="Close" binding="escape" side="left">
|
<Hint label={t("Close")} binding="escape" side="left">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Close setup"
|
aria-label={t("Close setup")}
|
||||||
data-testid="setup-close"
|
data-testid="setup-close"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="ml-auto text-muted-foreground transition-colors hover:text-foreground"
|
className="ml-auto text-muted-foreground transition-colors hover:text-foreground"
|
||||||
@@ -425,15 +434,14 @@ export function SetupDialog({
|
|||||||
<div className="flex flex-col px-6 py-5">
|
<div className="flex flex-col px-6 py-5">
|
||||||
{failed ? (
|
{failed ? (
|
||||||
<p className="text-sm text-foreground">
|
<p className="text-sm text-foreground">
|
||||||
Model catalog could not be loaded. Check the connection and retry.
|
{t("Model catalog could not be loaded. Check the connection and retry.")}</p>
|
||||||
</p>
|
|
||||||
) : (
|
) : (
|
||||||
<SetupKeysBody
|
<SetupKeysBody
|
||||||
providers={keyProviders}
|
providers={keyProviders}
|
||||||
provider={keyProvider}
|
provider={keyProvider}
|
||||||
value={keyValue}
|
value={keyValue}
|
||||||
saving={keySaving}
|
saving={keySaving}
|
||||||
error={keyError}
|
error={t(keyError)}
|
||||||
menuOpen={keyMenuOpen}
|
menuOpen={keyMenuOpen}
|
||||||
menuAnchor={keyTriggerRef}
|
menuAnchor={keyTriggerRef}
|
||||||
onToggleMenu={() => setKeyMenuOpen((v) => !v)}
|
onToggleMenu={() => setKeyMenuOpen((v) => !v)}
|
||||||
@@ -450,8 +458,7 @@ export function SetupDialog({
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-4 overflow-y-auto px-6 py-5" data-testid="setup-stage-pickers">
|
<div className="flex flex-col gap-4 overflow-y-auto px-6 py-5" data-testid="setup-stage-pickers">
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Pick the model for each tier. All three are required before chats can start.
|
{t("Pick the model for each tier. All three are required before chats can start.")}</p>
|
||||||
</p>
|
|
||||||
{SLOT_ORDER.map((slot) => {
|
{SLOT_ORDER.map((slot) => {
|
||||||
const entry = picked[slot];
|
const entry = picked[slot];
|
||||||
const level = levels[slot];
|
const level = levels[slot];
|
||||||
@@ -461,28 +468,28 @@ export function SetupDialog({
|
|||||||
return (
|
return (
|
||||||
<div key={slot} data-testid={`setup-slot-${slot}`} className="flex flex-col gap-1.5">
|
<div key={slot} data-testid={`setup-slot-${slot}`} className="flex flex-col gap-1.5">
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
<span className="font-ui text-sm text-foreground">{SLOT_META[slot].label}</span>
|
<span className="font-ui text-sm text-foreground">{t(SLOT_META[slot].label)}</span>
|
||||||
{!body?.slots?.[slot]?.model && !picked[slot] && (
|
{!body?.slots?.[slot]?.model && !picked[slot] && (
|
||||||
<span
|
<span
|
||||||
data-testid={`setup-needed-${slot}`}
|
data-testid={`setup-needed-${slot}`}
|
||||||
className="rounded-sm border border-border px-1 py-0.5 text-[10px] font-normal tracking-wide text-muted-foreground"
|
className="rounded-sm border border-border px-1 py-0.5 text-[10px] font-normal tracking-wide text-muted-foreground"
|
||||||
>
|
>
|
||||||
needed
|
{t("needed")}</span>
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground/70">{SLOT_META[slot].roster}</span>
|
<span className="text-xs text-muted-foreground/70">{t(SLOT_META[slot].roster)}</span>
|
||||||
|
{slot !== "strong" && <><ChatGPTBackgroundAccess provider={entry?.provider} /><NativeSubscriptionBackgroundAccess provider={entry?.provider} /></>}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={`${SLOT_META[slot].label} model`}
|
aria-label={t("{v0} model", { v0: SLOT_META[slot].label })}
|
||||||
data-testid={`setup-trigger-${slot}`}
|
data-testid={`setup-trigger-${slot}`}
|
||||||
aria-expanded={openMenu?.slot === slot && openMenu.kind === "model"}
|
aria-expanded={openMenu?.slot === slot && openMenu.kind === "model"}
|
||||||
onClick={() => setOpenMenu(openMenu?.slot === slot && openMenu.kind === "model" ? null : { slot, kind: "model" })}
|
onClick={() => setOpenMenu(openMenu?.slot === slot && openMenu.kind === "model" ? null : { slot, kind: "model" })}
|
||||||
className="flex h-7 min-w-40 w-full items-center gap-1 rounded-sm border border-border bg-background px-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50"
|
className="flex h-7 min-w-40 w-full items-center gap-1 rounded-sm border border-border bg-background px-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50"
|
||||||
>
|
>
|
||||||
<span className="min-w-0 flex-1 truncate">{entry ? prettyModel(entry.id) : "Pick a model…"}</span>
|
<span className="min-w-0 flex-1 truncate">{entry ? prettyModel(entry.id) : t("Pick a model…")}</span>
|
||||||
<span aria-hidden className="text-muted-foreground">
|
<span aria-hidden className="text-muted-foreground">
|
||||||
▾
|
▾
|
||||||
</span>
|
</span>
|
||||||
@@ -520,7 +527,7 @@ export function SetupDialog({
|
|||||||
}));
|
}));
|
||||||
setOpenMenu(null);
|
setOpenMenu(null);
|
||||||
}}
|
}}
|
||||||
footer={catalog && catalog.length === 0 ? "No image-capable models. Add a provider key with access to a vision model." : undefined}
|
footer={catalog && catalog.length === 0 ? t("No image-capable models. Add a provider key with access to a vision model.") : undefined}
|
||||||
/>
|
/>
|
||||||
</MenuShell>
|
</MenuShell>
|
||||||
)}
|
)}
|
||||||
@@ -530,7 +537,7 @@ export function SetupDialog({
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={`${SLOT_META[slot].label} thinking`}
|
aria-label={t("{v0} thinking", { v0: SLOT_META[slot].label })}
|
||||||
data-testid={`setup-thinking-${slot}`}
|
data-testid={`setup-thinking-${slot}`}
|
||||||
aria-expanded={openMenu?.slot === slot && openMenu.kind === "thinking"}
|
aria-expanded={openMenu?.slot === slot && openMenu.kind === "thinking"}
|
||||||
disabled={!entry}
|
disabled={!entry}
|
||||||
@@ -575,9 +582,9 @@ export function SetupDialog({
|
|||||||
onClick={() => void save()}
|
onClick={() => void save()}
|
||||||
className="rounded-sm border border-border bg-background px-3 py-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50 disabled:cursor-not-allowed disabled:opacity-40"
|
className="rounded-sm border border-border bg-background px-3 py-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
>
|
>
|
||||||
{saving ? "Saving…" : "Save models"}
|
{saving ? t("Saving…") : t("Save models")}
|
||||||
</button>
|
</button>
|
||||||
{error && <p className="text-xs text-red-400">{error}</p>}
|
{error && <p className="text-xs text-red-400">{t(error)}</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+57
-41
@@ -1,4 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { translator, type Language } from "@/src/core";
|
||||||
|
import { useI18n } from "@/components/i18n";
|
||||||
|
|
||||||
|
|
||||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -21,6 +24,7 @@ import { fuzzyScore } from "@/src/core";
|
|||||||
import { useServerEvent, useServerResync } from "@/components/server-events";
|
import { useServerEvent, useServerResync } from "@/components/server-events";
|
||||||
import type { ChatListItem } from "@/src/core";
|
import type { ChatListItem } from "@/src/core";
|
||||||
import { kbdEventForAction } from "@/src/core";
|
import { kbdEventForAction } from "@/src/core";
|
||||||
|
import { notifySettingsSaved } from "@/components/settings-signal";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggleable sidebar — NOT a modal drawer: no backdrop, no focus theft. It
|
* Toggleable sidebar — NOT a modal drawer: no backdrop, no focus theft. It
|
||||||
@@ -47,6 +51,7 @@ const STORAGE_KEY = "lh:sidebar-open";
|
|||||||
*
|
*
|
||||||
* Mirrors SIDEBAR_AUTOCLOSE in electron/main.cjs — keep in sync.
|
* Mirrors SIDEBAR_AUTOCLOSE in electron/main.cjs — keep in sync.
|
||||||
*/
|
*/
|
||||||
|
import type { ChatGPTControlBridge, NativeSubscriptionControlBridge } from "@/src/core";
|
||||||
const SIDEBAR_AUTOCLOSE = 928;
|
const SIDEBAR_AUTOCLOSE = 928;
|
||||||
|
|
||||||
// Module scope, guarded for SSR (the server has no navigator).
|
// Module scope, guarded for SSR (the server has no navigator).
|
||||||
@@ -58,10 +63,13 @@ declare global {
|
|||||||
lh?: {
|
lh?: {
|
||||||
/** Read-only host OS; determines the native-control gutter. */
|
/** Read-only host OS; determines the native-control gutter. */
|
||||||
platform?: string;
|
platform?: string;
|
||||||
|
chatgpt?: ChatGPTControlBridge;
|
||||||
|
subscriptions?: NativeSubscriptionControlBridge;
|
||||||
expandWindow: () => void;
|
expandWindow: () => void;
|
||||||
/** The zoom truth (owner round 7): subscribes to "lh:zoom" pushes; returns unsubscribe. */
|
/** The zoom truth (owner round 7): subscribes to "lh:zoom" pushes; returns unsubscribe. */
|
||||||
onZoom?: (cb: (factor: number) => void) => () => void;
|
onZoom?: (cb: (factor: number) => void) => () => void;
|
||||||
getZoom?: () => Promise<number>;
|
getZoom?: () => Promise<number>;
|
||||||
|
setLanguage?: (language: "en" | "ru") => void;
|
||||||
/** Auto-update channel (Wave C, 2026-10-10): "lh:update-status" pushes once a staged update exists. */
|
/** Auto-update channel (Wave C, 2026-10-10): "lh:update-status" pushes once a staged update exists. */
|
||||||
onUpdateStatus?: (cb: (status: { status: string; version: string | null }) => void) => () => void;
|
onUpdateStatus?: (cb: (status: { status: string; version: string | null }) => void) => () => void;
|
||||||
installUpdate?: () => void;
|
installUpdate?: () => void;
|
||||||
@@ -79,6 +87,13 @@ export function useSidebar() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SidebarProvider({ children }: { children: React.ReactNode }) {
|
export function SidebarProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
useEffect(() => {
|
||||||
|
const bridge = window.lh?.chatgpt;
|
||||||
|
if (!bridge) return;
|
||||||
|
const off = bridge.onChange(() => notifySettingsSaved("keys"));
|
||||||
|
notifySettingsSaved("keys"); // cover changes missed before this root-lifetime subscription
|
||||||
|
return off;
|
||||||
|
}, []);
|
||||||
// Start closed so server and client first-render agree (no hydration
|
// Start closed so server and client first-render agree (no hydration
|
||||||
// mismatch); the stored value is applied after mount.
|
// mismatch); the stored value is applied after mount.
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@@ -177,6 +192,7 @@ export function SidebarProvider({ children }: { children: React.ReactNode }) {
|
|||||||
* The icon fades with the slot and is clipped by it as it collapses —
|
* The icon fades with the slot and is clipped by it as it collapses —
|
||||||
* no popping in either direction. */
|
* no popping in either direction. */
|
||||||
export function SidebarToggle() {
|
export function SidebarToggle() {
|
||||||
|
const { t } = useI18n();
|
||||||
const { open, toggle } = useSidebar();
|
const { open, toggle } = useSidebar();
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
@@ -185,11 +201,11 @@ export function SidebarToggle() {
|
|||||||
open ? "w-0 -mr-3" : "w-9 mr-0 lh-closed-toggle",
|
open ? "w-0 -mr-3" : "w-9 mr-0 lh-closed-toggle",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Hint label="Open sidebar" action="toggle-sidebar">
|
<Hint label={t("Open sidebar")} action="toggle-sidebar">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
aria-label="Open sidebar"
|
aria-label={t("Open sidebar")}
|
||||||
aria-hidden={open}
|
aria-hidden={open}
|
||||||
tabIndex={open ? -1 : 0}
|
tabIndex={open ? -1 : 0}
|
||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
@@ -228,16 +244,18 @@ function SessionStatus({ busy }: { busy: boolean }) {
|
|||||||
* in any locale and it sorts (owner, typography round 5; the old
|
* in any locale and it sorts (owner, typography round 5; the old
|
||||||
* M/D/YYYY was the US convention. A user-locale setting can come later
|
* M/D/YYYY was the US convention. A user-locale setting can come later
|
||||||
* — trivial, a format function swap). Pure — exported for tests. */
|
* — trivial, a format function swap). Pure — exported for tests. */
|
||||||
export function timeAgo(iso: string, now = new Date()): string {
|
export function timeAgo(iso: string, now = new Date(), language: Language = "en"): string {
|
||||||
|
const t = translator(language);
|
||||||
const ms = now.getTime() - new Date(iso).getTime();
|
const ms = now.getTime() - new Date(iso).getTime();
|
||||||
if (ms < 60_000) return "just now";
|
if (ms < 60_000) return t("just now");
|
||||||
const min = Math.floor(ms / 60_000);
|
const min = Math.floor(ms / 60_000);
|
||||||
if (min < 60) return `${min}m ago`;
|
if (min < 60) return t("{v0}m ago", { v0: min });
|
||||||
const h = Math.floor(min / 60);
|
const h = Math.floor(min / 60);
|
||||||
if (h < 24) return `${h}h ago`;
|
if (h < 24) return t("{v0}h ago", { v0: h });
|
||||||
const d = Math.floor(h / 24);
|
const d = Math.floor(h / 24);
|
||||||
if (d < 7) return `${d}d ago`;
|
if (d < 7) return t("{v0}d ago", { v0: d });
|
||||||
const date = new Date(iso);
|
const date = new Date(iso);
|
||||||
|
if (language === "ru") return new Intl.DateTimeFormat("ru-RU").format(date);
|
||||||
const mm = String(date.getMonth() + 1).padStart(2, "0");
|
const mm = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
const dd = String(date.getDate()).padStart(2, "0");
|
const dd = String(date.getDate()).padStart(2, "0");
|
||||||
return `${date.getFullYear()}-${mm}-${dd}`;
|
return `${date.getFullYear()}-${mm}-${dd}`;
|
||||||
@@ -248,14 +266,15 @@ export function timeAgo(iso: string, now = new Date()): string {
|
|||||||
* typography round 5): it informed no decision — clutter. messageCount
|
* typography round 5): it informed no decision — clutter. messageCount
|
||||||
* still rides the API payload, so this is a one-line flip back if it's
|
* still rides the API payload, so this is a one-line flip back if it's
|
||||||
* ever wanted. */
|
* ever wanted. */
|
||||||
function sessionMeta(chat: ChatListItem): string {
|
function sessionMeta(chat: ChatListItem, language: Language = "en"): string {
|
||||||
if (chat.compacting) return `Compacting context · ${timeAgo(chat.updatedAt)}`;
|
const t = translator(language);
|
||||||
if (chat.pendingQuestion) return `Waiting for input · ${timeAgo(chat.updatedAt)}`;
|
if (chat.compacting) return t("Compacting context · {v0}", { v0: timeAgo(chat.updatedAt, undefined, language) });
|
||||||
|
if (chat.pendingQuestion) return t("Waiting for input · {v0}", { v0: timeAgo(chat.updatedAt, undefined, language) });
|
||||||
if ((chat.kind ?? "teach") === "practice" && chat.pendingProblem) {
|
if ((chat.kind ?? "teach") === "practice" && chat.pendingProblem) {
|
||||||
return `Problem waiting · ${timeAgo(chat.updatedAt)}`;
|
return t("Problem waiting · {v0}", { v0: timeAgo(chat.updatedAt, undefined, language) });
|
||||||
}
|
}
|
||||||
if (chat.busy) return "Working…";
|
if (chat.busy) return t("Working…");
|
||||||
return timeAgo(chat.updatedAt);
|
return timeAgo(chat.updatedAt, undefined, language);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -279,6 +298,7 @@ export function SessionRow({
|
|||||||
/** Right-click opens the context menu (wave 6: Rename / Delete). */
|
/** Right-click opens the context menu (wave 6: Rename / Delete). */
|
||||||
onContextMenu?: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
onContextMenu?: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t, language } = useI18n();
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -299,13 +319,13 @@ export function SessionRow({
|
|||||||
rule, finally applied to the picker too (owner, typography
|
rule, finally applied to the picker too (owner, typography
|
||||||
round): mono = chrome, Source Sans = human words. */}
|
round): mono = chrome, Source Sans = human words. */}
|
||||||
<span className="block truncate font-prose text-(length:--lh-prose-size)">{chat.title}</span>
|
<span className="block truncate font-prose text-(length:--lh-prose-size)">{chat.title}</span>
|
||||||
<span className="block text-xs text-muted-foreground">{sessionMeta(chat)}</span>
|
<span className="block text-xs text-muted-foreground">{sessionMeta(chat, language)}</span>
|
||||||
</span>
|
</span>
|
||||||
{/* The dumbbell rides the FAR RIGHT (owner round 6, 2026-10-05) —
|
{/* The dumbbell rides the FAR RIGHT (owner round 6, 2026-10-05) —
|
||||||
was left of the name; right-aligned keeps the titles flush with
|
was left of the name; right-aligned keeps the titles flush with
|
||||||
the teaching rows and reads cleaner. */}
|
the teaching rows and reads cleaner. */}
|
||||||
{(chat.kind ?? "teach") === "practice" && (
|
{(chat.kind ?? "teach") === "practice" && (
|
||||||
<DumbbellIcon className="size-3.5 shrink-0 text-muted-foreground/70" aria-label="Practice session" />
|
<DumbbellIcon className="size-3.5 shrink-0 text-muted-foreground/70" aria-label={t("Practice session")} />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -316,6 +336,7 @@ export function SessionRow({
|
|||||||
* "replace", the header rename's rule), Enter/blur commits, Esc discards.
|
* "replace", the header rename's rule), Enter/blur commits, Esc discards.
|
||||||
* The meta line stays so the row never changes height mid-edit. */
|
* The meta line stays so the row never changes height mid-edit. */
|
||||||
export function RenameRow({ chat, onCommit, onCancel }: { chat: ChatListItem; onCommit: (next: string) => void; onCancel: () => void }) {
|
export function RenameRow({ chat, onCommit, onCancel }: { chat: ChatListItem; onCommit: (next: string) => void; onCancel: () => void }) {
|
||||||
|
const { t, language } = useI18n();
|
||||||
const [draft, setDraft] = useState(chat.title);
|
const [draft, setDraft] = useState(chat.title);
|
||||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -351,11 +372,11 @@ export function RenameRow({ chat, onCommit, onCancel }: { chat: ChatListItem; on
|
|||||||
onCancel();
|
onCancel();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
aria-label="Rename session"
|
aria-label={t("Rename session")}
|
||||||
data-testid="session-rename-input"
|
data-testid="session-rename-input"
|
||||||
className="lh-input w-full min-w-0 truncate border-0 border-b border-border bg-transparent px-0 text-foreground outline-none"
|
className="lh-input w-full min-w-0 truncate border-0 border-b border-border bg-transparent px-0 text-foreground outline-none"
|
||||||
/>
|
/>
|
||||||
<span className="block truncate text-xs text-muted-foreground">{sessionMeta(chat)}</span>
|
<span className="block truncate text-xs text-muted-foreground">{sessionMeta(chat, language)}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -368,6 +389,7 @@ export type SessionKindFilter = "teach" | "all" | "practice";
|
|||||||
const KIND_FILTER_KEY = "lh:session-kind-filter";
|
const KIND_FILTER_KEY = "lh:session-kind-filter";
|
||||||
|
|
||||||
function SessionsSidebar() {
|
function SessionsSidebar() {
|
||||||
|
const { t } = useI18n();
|
||||||
const { open, toggle } = useSidebar();
|
const { open, toggle } = useSidebar();
|
||||||
const [chats, setChats] = useState<ChatListItem[] | null>(null);
|
const [chats, setChats] = useState<ChatListItem[] | null>(null);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
@@ -645,23 +667,23 @@ function SessionsSidebar() {
|
|||||||
// of the logo, carrying its real shortcut: ⌘G jumps to
|
// of the logo, carrying its real shortcut: ⌘G jumps to
|
||||||
// the graph view (keyboard-shortcuts routes it to "/",
|
// the graph view (keyboard-shortcuts routes it to "/",
|
||||||
// the same destination as this logo).
|
// the same destination as this logo).
|
||||||
label="Home"
|
label={t("Home")}
|
||||||
action="jump-graph"
|
action="jump-graph"
|
||||||
side="right"
|
side="right"
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
aria-label="Home"
|
aria-label={t("Home")}
|
||||||
className="lh-no-drag flex size-9 shrink-0 items-center justify-center rounded-md transition-colors hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50"
|
className="lh-no-drag flex size-9 shrink-0 items-center justify-center rounded-md transition-colors hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50"
|
||||||
>
|
>
|
||||||
<LhMark className="size-6" />
|
<LhMark className="size-6" />
|
||||||
</Link>
|
</Link>
|
||||||
</Hint>
|
</Hint>
|
||||||
<Hint label="Close sidebar" action="toggle-sidebar">
|
<Hint label={t("Close sidebar")} action="toggle-sidebar">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
aria-label="Close sidebar"
|
aria-label={t("Close sidebar")}
|
||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
className="lh-no-drag"
|
className="lh-no-drag"
|
||||||
>
|
>
|
||||||
@@ -682,8 +704,7 @@ function SessionsSidebar() {
|
|||||||
className="flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-xs uppercase tracking-wider text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
className="flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-xs uppercase tracking-wider text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||||
>
|
>
|
||||||
<PlusIcon className="size-3" />
|
<PlusIcon className="size-3" />
|
||||||
Teaching
|
{t("Teaching")}<span className="sr-only">{t("create a new teaching session")}</span>
|
||||||
<span className="sr-only">create a new teaching session</span>
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -692,14 +713,13 @@ function SessionsSidebar() {
|
|||||||
className="flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-xs uppercase tracking-wider text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
className="flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-xs uppercase tracking-wider text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||||
>
|
>
|
||||||
<DumbbellIcon className="size-3" />
|
<DumbbellIcon className="size-3" />
|
||||||
Practice
|
{t("Practice")}<span className="sr-only">{t("create a new practice session")}</span>
|
||||||
<span className="sr-only">create a new practice session</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mx-3 mb-2 flex items-center gap-2">
|
<div className="mx-3 mb-2 flex items-center gap-2">
|
||||||
<span className="text-xs uppercase tracking-widest text-muted-foreground/70">Sessions</span>
|
<span className="text-xs uppercase tracking-widest text-muted-foreground/70">{t("Sessions")}</span>
|
||||||
<Hint label="Search sessions" action="sessions-search" side="bottom">
|
<Hint label={t("Search sessions")} action="sessions-search" side="bottom">
|
||||||
<span className="relative flex-1">
|
<span className="relative flex-1">
|
||||||
<Input
|
<Input
|
||||||
ref={searchRef}
|
ref={searchRef}
|
||||||
@@ -749,8 +769,8 @@ function SessionsSidebar() {
|
|||||||
e.currentTarget.blur();
|
e.currentTarget.blur();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="Search…"
|
placeholder={t("Search…")}
|
||||||
aria-label="Search sessions"
|
aria-label={t("Search sessions")}
|
||||||
className="h-6 rounded-sm border-border bg-background pl-6 text-xs placeholder:text-muted-foreground/60"
|
className="h-6 rounded-sm border-border bg-background pl-6 text-xs placeholder:text-muted-foreground/60"
|
||||||
/>
|
/>
|
||||||
<SearchIcon className="pointer-events-none absolute left-1.5 top-1/2 size-3 -translate-y-1/2 text-muted-foreground/70" />
|
<SearchIcon className="pointer-events-none absolute left-1.5 top-1/2 size-3 -translate-y-1/2 text-muted-foreground/70" />
|
||||||
@@ -779,17 +799,17 @@ function SessionsSidebar() {
|
|||||||
: "text-muted-foreground hover:text-foreground",
|
: "text-muted-foreground hover:text-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{f === "teach" ? "Teaching" : f === "all" ? "All" : "Practice"}
|
{f === "teach" ? t("Teaching") : f === "all" ? t("All") : t("Practice")}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div ref={listRef} className="flex-1 overflow-y-auto p-2">
|
<div ref={listRef} className="flex-1 overflow-y-auto p-2">
|
||||||
{shown === null ? (
|
{shown === null ? (
|
||||||
<div className="px-2 py-4 text-xs text-muted-foreground">Loading…</div>
|
<div className="px-2 py-4 text-xs text-muted-foreground">{t("Loading…")}</div>
|
||||||
) : shown.length === 0 ? (
|
) : shown.length === 0 ? (
|
||||||
<div className="px-2 py-4 text-xs text-muted-foreground">
|
<div className="px-2 py-4 text-xs text-muted-foreground">
|
||||||
{query.trim() ? "No sessions match." : "No sessions yet."}
|
{query.trim() ? t("No sessions match.") : t("No sessions yet.")}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
shown.map((c, i) =>
|
shown.map((c, i) =>
|
||||||
@@ -828,7 +848,7 @@ function SessionsSidebar() {
|
|||||||
New session — quiet, bottom-pinned, opens the centered
|
New session — quiet, bottom-pinned, opens the centered
|
||||||
dialog (also mod+, in the keyboard registry). */}
|
dialog (also mod+, in the keyboard registry). */}
|
||||||
<div className="px-3 pb-3 pt-1">
|
<div className="px-3 pb-3 pt-1">
|
||||||
<Hint label="Settings" action="open-settings" side="right">
|
<Hint label={t("Settings")} action="open-settings" side="right">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => openSettings()}
|
onClick={() => openSettings()}
|
||||||
@@ -836,8 +856,7 @@ function SessionsSidebar() {
|
|||||||
className="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs uppercase tracking-wider text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
className="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs uppercase tracking-wider text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||||
>
|
>
|
||||||
<SettingsIcon className="size-3" />
|
<SettingsIcon className="size-3" />
|
||||||
Settings
|
{t("Settings")}</button>
|
||||||
</button>
|
|
||||||
</Hint>
|
</Hint>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -868,8 +887,7 @@ function SessionsSidebar() {
|
|||||||
}}
|
}}
|
||||||
className="flex items-center rounded-lg px-2.5 py-1.5 text-left text-foreground transition-colors hover:bg-accent"
|
className="flex items-center rounded-lg px-2.5 py-1.5 text-left text-foreground transition-colors hover:bg-accent"
|
||||||
>
|
>
|
||||||
Rename
|
{t("Rename")}</button>
|
||||||
</button>
|
|
||||||
{/* Export as Markdown (owner, release polish 2026-10-06): the
|
{/* Export as Markdown (owner, release polish 2026-10-06): the
|
||||||
session-markdown engine's user-facing debut — same-origin
|
session-markdown engine's user-facing debut — same-origin
|
||||||
anchor download; the route's Content-Disposition names the
|
anchor download; the route's Content-Disposition names the
|
||||||
@@ -890,8 +908,7 @@ function SessionsSidebar() {
|
|||||||
}}
|
}}
|
||||||
className="flex items-center rounded-lg px-2.5 py-1.5 text-left text-foreground transition-colors hover:bg-accent"
|
className="flex items-center rounded-lg px-2.5 py-1.5 text-left text-foreground transition-colors hover:bg-accent"
|
||||||
>
|
>
|
||||||
Export as Markdown…
|
{t("Export as Markdown…")}</button>
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="ctx-delete"
|
data-testid="ctx-delete"
|
||||||
@@ -902,8 +919,7 @@ function SessionsSidebar() {
|
|||||||
}}
|
}}
|
||||||
className="flex items-center rounded-lg px-2.5 py-1.5 text-left text-muted-foreground/80 transition-colors hover:bg-accent hover:text-foreground"
|
className="flex items-center rounded-lg px-2.5 py-1.5 text-left text-muted-foreground/80 transition-colors hover:bg-accent hover:text-foreground"
|
||||||
>
|
>
|
||||||
Delete…
|
{t("Delete…")}</button>
|
||||||
</button>
|
|
||||||
<div className="my-0.5 border-t border-border/60" aria-hidden="true" />
|
<div className="my-0.5 border-t border-border/60" aria-hidden="true" />
|
||||||
<CoverageMenuBlock chatId={ctxChatId} />
|
<CoverageMenuBlock chatId={ctxChatId} />
|
||||||
</MenuShell>
|
</MenuShell>
|
||||||
|
|||||||
+41
-37
@@ -1,10 +1,17 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { useI18n } from "@/components/i18n";
|
||||||
|
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||||
import { contextWindow, sessionCost, turnCost, type ChatSettings, type TurnUsage } from "@/src/core";
|
import { subscriptionLabels, subscriptionLabel, type Language, type SessionPricing, type TurnUsage } from "@/src/core";
|
||||||
import { formatTokens, formatUsd, prettyModel } from "@/components/usage-format";
|
import { formatTokens, formatUsd, planCostLabel, prettyModel } from "@/components/usage-format";
|
||||||
import { ModelControl, catalogLimits, useModelCatalog } from "@/components/model-picker";
|
import { ModelControl, catalogLimits, useModelCatalog } from "@/components/model-picker";
|
||||||
|
|
||||||
|
export function formatContextMeasurement(tokens: number | null, limit: number | null, language: Language = "en"): string {
|
||||||
|
const numerator = tokens === null ? "?" : formatTokens(tokens, language);
|
||||||
|
return limit === null ? numerator : `${numerator} / ${formatTokens(limit, language)}`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The below-composer stats bar (wave 4 observability): the session's
|
* The below-composer stats bar (wave 4 observability): the session's
|
||||||
* money/context footprint as one quiet row beneath the composer —
|
* money/context footprint as one quiet row beneath the composer —
|
||||||
@@ -16,23 +23,23 @@ import { ModelControl, catalogLimits, useModelCatalog } from "@/components/model
|
|||||||
* there died with the injection; a "— · —" summary over an empty
|
* there died with the injection; a "— · —" summary over an empty
|
||||||
* popover is pure noise) — the dictation strip's middle simply widens.
|
* popover is pure noise) — the dictation strip's middle simply widens.
|
||||||
*
|
*
|
||||||
* All folds come from core/prices.ts (sessionCost, contextWindow, turnCost)
|
* Spending arrives already priced by the server through Pi's catalog and
|
||||||
* — no cost math lives here. Absent data renders "—", never zero (the
|
* calculator. Missing cost stays "—", never fake zero. Context is a separate
|
||||||
* doctrine): no turns yet → every figure is "—"; turns the price table
|
* completed-conversation measurement, never a money-log/summarizer fold or
|
||||||
* can't price count as unpriced, shown in the popover, never folded into
|
* transcript estimate. Its catalog denominator follows the selected model;
|
||||||
* a fake $0. The RIGHT side is the model + thinking control (components/
|
* model changes preserve the numerator. Successful compaction clears it to ?.
|
||||||
* model-picker.tsx): one controller for the conversation agents, backed by
|
|
||||||
* pi-ai's catalog; the buttons show the EFFECTIVE model (the record's
|
|
||||||
* settings, else config's pin), while the left-side figures still fold the
|
|
||||||
* turn log's actual billed models.
|
|
||||||
*/
|
*/
|
||||||
export function StatsBar({
|
export function StatsBar({
|
||||||
turns,
|
turns,
|
||||||
|
contextTokens = null,
|
||||||
|
pricing,
|
||||||
dictationSlot,
|
dictationSlot,
|
||||||
chatId,
|
chatId,
|
||||||
leadingSlot,
|
leadingSlot,
|
||||||
}: {
|
}: {
|
||||||
turns: TurnUsage[];
|
turns: TurnUsage[];
|
||||||
|
contextTokens?: number | null;
|
||||||
|
pricing?: SessionPricing;
|
||||||
/** The bound session (per-session models, owner 2026-09-28): present
|
/** The bound session (per-session models, owner 2026-09-28): present
|
||||||
* on the in-session bar — the model control binds THE SESSION'S own
|
* on the in-session bar — the model control binds THE SESSION'S own
|
||||||
* record; absent on the draft surfaces, where it binds the strong
|
* record; absent on the draft surfaces, where it binds the strong
|
||||||
@@ -48,7 +55,9 @@ export function StatsBar({
|
|||||||
* data (which renders nothing pre-chat on that surface). */
|
* data (which renders nothing pre-chat on that surface). */
|
||||||
leadingSlot?: ReactNode;
|
leadingSlot?: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
|
const { t, language } = useI18n();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [selectedModel, setSelectedModel] = useState<string | undefined>();
|
||||||
// Same dropdown discipline as the model control (owner, df4433f treatment,
|
// Same dropdown discipline as the model control (owner, df4433f treatment,
|
||||||
// stats round 1): close on any pointerdown outside the bar or on Escape.
|
// stats round 1): close on any pointerdown outside the bar or on Escape.
|
||||||
const barRef = useRef<HTMLDivElement>(null);
|
const barRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -73,22 +82,18 @@ export function StatsBar({
|
|||||||
document.removeEventListener("keydown", onKeyDown);
|
document.removeEventListener("keydown", onKeyDown);
|
||||||
};
|
};
|
||||||
}, [open]);
|
}, [open]);
|
||||||
// The live-limits join (context-meter amendment, owner 2026-09-23):
|
// Catalog-only denominator; the picker reports its effective selection.
|
||||||
// contextWindow's denominator resolves ONLY from pi's live catalog —
|
// Missing/pending catalog means usage-only, never a hardcoded maximum.
|
||||||
// built client-side over the picker's cached /api/models fetch (the
|
|
||||||
// same fetch ModelControl below rides; module-level dedupe). Catalog
|
|
||||||
// pending or failed → undefined index → limit null → USAGE-ONLY (the
|
|
||||||
// honest-render precedent, never a fake window); the usage-only render
|
|
||||||
// IS the null path.
|
|
||||||
const { catalog } = useModelCatalog();
|
const { catalog } = useModelCatalog();
|
||||||
const limits = useMemo(() => (catalog ? catalogLimits(catalog) : undefined), [catalog]);
|
const limits = useMemo(() => (catalog ? catalogLimits(catalog) : undefined), [catalog]);
|
||||||
const ctx = contextWindow({ turns }, limits);
|
const limit = selectedModel ? limits?.get(selectedModel) ?? null : null;
|
||||||
const { cost, unpriced } = sessionCost({ turns });
|
const contextLabel = formatContextMeasurement(contextTokens, limit, language);
|
||||||
|
const { cost = 0, unpriced = turns.length, subscription = 0 } = pricing ?? {};
|
||||||
// Honest cost: show dollars only when at least one turn priced;
|
// Honest cost: show dollars only when at least one turn priced;
|
||||||
// otherwise "—" (never a fake $0.0000).
|
// otherwise "—" (never a fake $0.0000).
|
||||||
const pricedCount = turns.length - unpriced;
|
const pricedCount = turns.length - unpriced - subscription;
|
||||||
const costLabel = pricedCount > 0 ? formatUsd(cost) : "—";
|
const providers = pricing?.subscriptionProviders ?? subscriptionLabels(turns.map((turn) => turn.model));
|
||||||
const lastModel = turns.at(-1)?.model;
|
const costLabel = planCostLabel(pricedCount > 0 ? cost : null, providers, (value) => formatUsd(value, language));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -110,7 +115,7 @@ export function StatsBar({
|
|||||||
{leadingSlot}
|
{leadingSlot}
|
||||||
{/* The left side: the session-data summary — only once a session
|
{/* The left side: the session-data summary — only once a session
|
||||||
exists (turns). Pre-chat renders nothing (see the header). */}
|
exists (turns). Pre-chat renders nothing (see the header). */}
|
||||||
{turns.length > 0 && (
|
{(chatId || turns.length > 0) && (
|
||||||
<div ref={barRef} className="relative">
|
<div ref={barRef} className="relative">
|
||||||
{/* No tooltip — the numbers describe themselves and the popover
|
{/* No tooltip — the numbers describe themselves and the popover
|
||||||
speaks on click (owner, subtlety round 2026-09-25). */}
|
speaks on click (owner, subtlety round 2026-09-25). */}
|
||||||
@@ -122,7 +127,7 @@ export function StatsBar({
|
|||||||
className="flex items-center gap-1.5 tabular-nums transition-colors hover:text-foreground"
|
className="flex items-center gap-1.5 tabular-nums transition-colors hover:text-foreground"
|
||||||
>
|
>
|
||||||
<span data-testid="stats-context">
|
<span data-testid="stats-context">
|
||||||
{ctx ? (ctx.limit !== null ? `${formatTokens(ctx.usedTokens)} / ${formatTokens(ctx.limit)}` : formatTokens(ctx.usedTokens)) : "—"}
|
{contextLabel}
|
||||||
</span>
|
</span>
|
||||||
<span aria-hidden>·</span>
|
<span aria-hidden>·</span>
|
||||||
<span data-testid="stats-cost">{costLabel}</span>
|
<span data-testid="stats-cost">{costLabel}</span>
|
||||||
@@ -133,22 +138,22 @@ export function StatsBar({
|
|||||||
className="absolute bottom-full left-1/2 z-40 mb-1.5 w-72 -translate-x-1/2 rounded-xl border bg-card p-3 text-xs shadow-xl"
|
className="absolute bottom-full left-1/2 z-40 mb-1.5 w-72 -translate-x-1/2 rounded-xl border bg-card p-3 text-xs shadow-xl"
|
||||||
>
|
>
|
||||||
<div className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
<div className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
Session stats
|
{t("Session stats")}</div>
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1 text-muted-foreground">
|
<div className="flex flex-col gap-1 text-muted-foreground">
|
||||||
<div className="flex justify-between gap-2">
|
<div className="flex justify-between gap-2">
|
||||||
<span>Context (last call)</span>
|
<span>{t("Context (last call)")}</span>
|
||||||
<span className="tabular-nums text-card-foreground">
|
<span className="tabular-nums text-card-foreground">
|
||||||
{ctx ? (ctx.limit !== null ? `${formatTokens(ctx.usedTokens)} / ${formatTokens(ctx.limit)}` : formatTokens(ctx.usedTokens)) : "—"}
|
{contextLabel}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between gap-2">
|
<div className="flex justify-between gap-2">
|
||||||
<span>Total cost</span>
|
<span>{subscription ? t("Usage") : t("Total cost")}</span>
|
||||||
<span className="tabular-nums text-card-foreground">{costLabel}</span>
|
<span className="tabular-nums text-card-foreground">{costLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{providers.includes("ChatGPT") && <a href="https://chatgpt.com/settings/usage" target="_blank" rel="noreferrer" className="underline underline-offset-2">{t("Manage ChatGPT usage")}</a>}
|
||||||
{unpriced > 0 && (
|
{unpriced > 0 && (
|
||||||
<div className="flex justify-between gap-2">
|
<div className="flex justify-between gap-2">
|
||||||
<span>Unpriced turns</span>
|
<span>{t("Unpriced turns")}</span>
|
||||||
<span className="tabular-nums text-card-foreground">{unpriced}</span>
|
<span className="tabular-nums text-card-foreground">{unpriced}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -156,19 +161,18 @@ export function StatsBar({
|
|||||||
{turns.length > 0 && (
|
{turns.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="mb-1 mt-2.5 border-t pt-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
<div className="mb-1 mt-2.5 border-t pt-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
Turns
|
{t("Turns")}</div>
|
||||||
</div>
|
|
||||||
<ul className="flex max-h-56 flex-col gap-1 overflow-y-auto">
|
<ul className="flex max-h-56 flex-col gap-1 overflow-y-auto">
|
||||||
{turns.map((turn, i) => {
|
{turns.map((turn, i) => {
|
||||||
const c = turnCost(turn);
|
const c = pricing?.turnCosts[turn.id] ?? null;
|
||||||
return (
|
return (
|
||||||
<li key={`${turn.messageId}-${i}`} className="flex items-center justify-between gap-2">
|
<li key={`${turn.messageId}-${i}`} className="flex items-center justify-between gap-2">
|
||||||
<span className="truncate text-muted-foreground">
|
<span className="truncate text-muted-foreground">
|
||||||
#{i + 1} · {prettyModel(turn.model, true)}
|
#{i + 1} · {prettyModel(turn.model, true)}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex shrink-0 items-center gap-2 tabular-nums text-card-foreground">
|
<span className="flex shrink-0 items-center gap-2 tabular-nums text-card-foreground">
|
||||||
<span className="text-muted-foreground">{turn.steps.length} steps</span>
|
<span className="text-muted-foreground">{turn.steps.length}{" "}{t("steps")}</span>
|
||||||
<span>{c === null ? "—" : formatUsd(c)}</span>
|
<span>{subscriptionLabel(turn.model) ?? (c === null ? "—" : formatUsd(c, language))}</span>
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
@@ -189,7 +193,7 @@ export function StatsBar({
|
|||||||
{/* The model + thinking control — one controller for the conversation
|
{/* The model + thinking control — one controller for the conversation
|
||||||
agents (prober/teacher); the record's `turns` above still folds
|
agents (prober/teacher); the record's `turns` above still folds
|
||||||
the ACTUAL models billed, regardless of what the picker says. */}
|
the ACTUAL models billed, regardless of what the picker says. */}
|
||||||
<ModelControl chatId={chatId} />
|
<ModelControl chatId={chatId} onModelChange={setSelectedModel} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { useI18n } from "@/components/i18n";
|
||||||
|
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { ChevronDownIcon } from "lucide-react";
|
import { ChevronDownIcon } from "lucide-react";
|
||||||
@@ -83,6 +85,7 @@ export function ThinkingBlock({
|
|||||||
/** On the card's own background: no band (the accent read too light). */
|
/** On the card's own background: no band (the accent read too light). */
|
||||||
inCard?: boolean;
|
inCard?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
const [open, setOpen] = useState(() => resolveUserThinkingOpen(defaultOpen));
|
const [open, setOpen] = useState(() => resolveUserThinkingOpen(defaultOpen));
|
||||||
// ⌘T: an explicit global set overrides every row (once set, rows re-sync).
|
// ⌘T: an explicit global set overrides every row (once set, rows re-sync).
|
||||||
const syncedRef = useRef(false);
|
const syncedRef = useRef(false);
|
||||||
@@ -123,7 +126,7 @@ export function ThinkingBlock({
|
|||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
className="flex w-full items-center gap-2 font-medium uppercase tracking-wide text-muted-foreground transition-colors hover:text-foreground"
|
className="flex w-full items-center gap-2 font-medium uppercase tracking-wide text-muted-foreground transition-colors hover:text-foreground"
|
||||||
>
|
>
|
||||||
{label}
|
{t(label)}
|
||||||
<ChevronDownIcon className={cn("size-4 shrink-0 transition-transform", open ? "rotate-180" : "rotate-0")} />
|
<ChevronDownIcon className={cn("size-4 shrink-0 transition-transform", open ? "rotate-180" : "rotate-0")} />
|
||||||
</button>
|
</button>
|
||||||
{open && (
|
{open && (
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import { useI18n } from "@/components/i18n";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { Tooltip, TooltipProvider, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"
|
import { Tooltip, TooltipProvider, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"
|
||||||
import { bindingForAction, formatBinding, type KeyAction } from "@/src/core"
|
import { bindingForAction, formatBinding, type KeyAction } from "@/src/core"
|
||||||
@@ -44,11 +46,12 @@ export function hintText(label: string, action?: KeyAction, binding?: string): s
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Hint({ label, action, binding, side = "top", children }: HintProps) {
|
export function Hint({ label, action, binding, side = "top", children }: HintProps) {
|
||||||
|
const { t } = useI18n();
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
||||||
<TooltipContent side={side}>{hintText(label, action, binding)}</TooltipContent>
|
<TooltipContent side={side}>{hintText(t(label), action, binding)}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { useI18n } from "@/components/i18n";
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The auto-update banner (Wave C, owner 2026-10-10: silent download +
|
* The auto-update banner (Wave C, owner 2026-10-10: silent download +
|
||||||
@@ -19,6 +21,7 @@ import { useEffect, useState } from "react";
|
|||||||
type UpdateStatus = { status: string; version: string | null };
|
type UpdateStatus = { status: string; version: string | null };
|
||||||
|
|
||||||
export default function UpdateBanner() {
|
export default function UpdateBanner() {
|
||||||
|
const { t } = useI18n();
|
||||||
const [ready, setReady] = useState<UpdateStatus | null>(null);
|
const [ready, setReady] = useState<UpdateStatus | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -35,16 +38,15 @@ export default function UpdateBanner() {
|
|||||||
className="fixed right-4 bottom-4 z-50 flex items-center gap-3 rounded-lg border border-border bg-popover px-4 py-3 shadow-lg"
|
className="fixed right-4 bottom-4 z-50 flex items-center gap-3 rounded-lg border border-border bg-popover px-4 py-3 shadow-lg"
|
||||||
>
|
>
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
<span className="font-medium">LearnHarness {ready.version ?? "update"} is ready</span>
|
<span className="font-medium">LearnHarness {ready.version ?? "update"}{" "}{t("is ready")}</span>
|
||||||
<span className="text-muted-foreground"> — restart to finish installing.</span>
|
<span className="text-muted-foreground"> {" "}{t("— restart to finish installing.")}</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => window.lh?.installUpdate?.()}
|
onClick={() => window.lh?.installUpdate?.()}
|
||||||
className="rounded-md bg-accent px-3 py-1.5 text-sm font-medium transition-colors hover:bg-accent/80"
|
className="rounded-md bg-accent px-3 py-1.5 text-sm font-medium transition-colors hover:bg-accent/80"
|
||||||
>
|
>
|
||||||
Restart
|
{t("Restart")}</button>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
|
import { localeTag, type Language } from "@/src/core";
|
||||||
/**
|
/**
|
||||||
* Money/token presentation helpers (wave-4 observability UI) — shared by the
|
* Money/token presentation helpers (wave-4 observability UI) — shared by the
|
||||||
* activity dock and the stats bar. Pure; exported for tests.
|
* activity dock and the stats bar. Pure; exported for tests.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** USD for cent-scale money: >= $1 keeps two decimals, smaller keeps four. */
|
/** USD for cent-scale money: >= $1 keeps two decimals, smaller keeps four. */
|
||||||
export function formatUsd(c: number): string {
|
export function formatUsd(c: number, language: Language = "en"): string {
|
||||||
|
if (language === "ru") return `${new Intl.NumberFormat(localeTag(language), { minimumFractionDigits: c >= 1 ? 2 : 4, maximumFractionDigits: c >= 1 ? 2 : 4 }).format(c)} $`;
|
||||||
return c >= 1 ? `$${c.toFixed(2)}` : `$${c.toFixed(4)}`;
|
return c >= 1 ? `$${c.toFixed(2)}` : `$${c.toFixed(4)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function planCostLabel(dollars: number | null, subscription: boolean | string | readonly string[] | undefined, format = formatUsd): string {
|
||||||
|
const labels = subscription === true ? ["ChatGPT"] : typeof subscription === "string" ? [subscription] : Array.isArray(subscription) ? [...new Set(subscription)] : [];
|
||||||
|
return [...(dollars === null ? [] : [format(dollars)]), ...labels].join(" + ") || "—";
|
||||||
|
}
|
||||||
|
|
||||||
/** 38000 → "38k", 1234567 → "1.2M" — trimmed, never "38.0k". */
|
/** 38000 → "38k", 1234567 → "1.2M" — trimmed, never "38.0k". */
|
||||||
export function formatTokens(n: number): string {
|
export function formatTokens(n: number, language: Language = "en"): string {
|
||||||
|
if (language === "ru") return new Intl.NumberFormat("ru-RU", { notation: "compact", maximumFractionDigits: 1 }).format(n);
|
||||||
if (n >= 1e6) return `${+(n / 1e6).toFixed(1)}M`;
|
if (n >= 1e6) return `${+(n / 1e6).toFixed(1)}M`;
|
||||||
if (n >= 1e3) return `${+(n / 1e3).toFixed(1)}k`;
|
if (n >= 1e3) return `${+(n / 1e3).toFixed(1)}k`;
|
||||||
return String(n);
|
return String(n);
|
||||||
|
|||||||
+10
-2
@@ -399,7 +399,7 @@ Practice turns the graph into problem sets the learner reasons through out loud.
|
|||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
subgraph DESKTOP["Electron app (production)"]
|
subgraph DESKTOP["Electron app (production)"]
|
||||||
MAIN["Electron main (electron/main.cjs)<br/>thin launcher + security guard<br/>1 IPC channel: lh:expand-window"] -->|"spawn: ELECTRON_RUN_AS_NODE,<br/>loopback port, LH_DATA_DIR=userData/data"| NEXT["standalone Next server (child)<br/>ALL app logic: turns · extraction · jobs · graph"]
|
MAIN["Electron main (electron/main.cjs)<br/>launcher + credential/security owner<br/>origin-pinned controls · private child IPC"] -->|"spawn: ELECTRON_RUN_AS_NODE,<br/>loopback port, LH_DATA_DIR=userData/data"| NEXT["standalone Next server (child)<br/>ALL app logic: turns · extraction · jobs · graph"]
|
||||||
MAIN -->|"loadURL, sandboxed renderer"| REND["Browser renderer (components/)<br/>fetch/WebSocket to own origin only"]
|
MAIN -->|"loadURL, sandboxed renderer"| REND["Browser renderer (components/)<br/>fetch/WebSocket to own origin only"]
|
||||||
end
|
end
|
||||||
subgraph OUTSIDE["outside the process"]
|
subgraph OUTSIDE["outside the process"]
|
||||||
@@ -417,10 +417,18 @@ flowchart LR
|
|||||||
REND -->|"fetch /api/*"| NEXT
|
REND -->|"fetch /api/*"| NEXT
|
||||||
```
|
```
|
||||||
|
|
||||||
The Electron main is a launcher + security guard, never a host for app logic: single-instance lock, deny-by-default permissions, origin-pinned navigation, sandboxed renderer. All Next.js code runs unchanged in a child process (`output: "standalone"`), relocated by the `LH_DATA_DIR` seam (also the test seam — `data-dir-seam.test.ts` covers it, including a fresh-child-process case). The build hard-fails if repo `data/` would leak into the bundle or if prompt assets fall out of the trace (`scripts/build-server.mjs`).
|
The Electron main is a launcher + credential/security guard, never a host for teaching or inference logic: single-instance lock, deny-by-default permissions, origin-pinned navigation, sandboxed renderer. All Next.js code runs unchanged in a child process (`output: "standalone"`), relocated by the `LH_DATA_DIR` seam (also the test seam — `data-dir-seam.test.ts` covers it, including a fresh-child-process case). The build hard-fails if repo `data/` would leak into the bundle or if prompt assets fall out of the trace (`scripts/build-server.mjs`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### 8.1 Subscription connection boundary
|
||||||
|
|
||||||
|
`electron/chatgpt.cjs` retains the existing verified-account ChatGPT authentication, storage and revocation behavior. `electron/subscriptions.cjs` delegates GitHub Copilot/Grok login and refresh to the installed Pi SDK through its public provider/Models interfaces. Their single saved connections live independently in owner-only, atomically written `github-copilot-credentials.json` / `xai-credentials.json` beside the existing app credential files. Electron main alone owns durable OAuth credentials and serialized refresh; no global Pi files or API-key credential precedence are involved.
|
||||||
|
|
||||||
|
The renderer gets projected status, public device user codes, prompts and controls over origin-pinned IPC. The privately launched server child receives credential-free catalogs and scoped request auth (including native headers/account endpoints), never refresh tokens. `src/agent/subscription-bridge.ts` rejects framework-only IPC and invalidates stale connection/catalog scopes. `subscription-stream.ts` dispatches directly through native providers, retaining native protocol identity on the wire and restoring logical app identity in assistant history. Persisted routes `chatgpt`, `github-copilot-subscription` and `xai-subscription` are distinct from ordinary API routes and never fall back to them.
|
||||||
|
|
||||||
|
`subscription-request-cycle.ts` is the shared admission/automatic-input recovery state machine; the old ChatGPT module is its compatibility export. Background consent is independent per provider, default ON, with foreground-preserving cancellation and queue ownership retained on no-dispatch. Native usage rejected before dispatch is transiently excluded from helper usage collectors, without adding persisted provenance fields. Catalogs and read-time pricing preserve the selected connection: subscription use displays compact provider labels, API dollars remain dollars, and mixed usage joins both without estimating subscription dollars or claiming detected tiers/allowances.
|
||||||
|
|
||||||
## 9. The foundational invariants — what ALWAYS holds
|
## 9. The foundational invariants — what ALWAYS holds
|
||||||
|
|
||||||
### Tier A — machine-enforced (`eslint.config.mjs`; lint fails the build)
|
### Tier A — machine-enforced (`eslint.config.mjs`; lint fails the build)
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# Compaction and usage accounting
|
||||||
|
|
||||||
|
## Retention
|
||||||
|
|
||||||
|
`src/core/compaction.ts` locally ports the cutoff-selection behavior and relevant
|
||||||
|
message-size heuristic from **@earendil-works/pi-coding-agent 1.4.0**. There is no
|
||||||
|
coding-agent dependency. Upgrade this port deliberately; the native golden
|
||||||
|
fixtures are in `tests/compaction-native-parity.test.ts`.
|
||||||
|
|
||||||
|
Walk backward to the approximately 20,000-token retained-tail budget, then select
|
||||||
|
the nearest valid user/assistant boundary at or after the crossing. Never start
|
||||||
|
at a tool result. If trailing results alone exceed the budget, keep the preceding
|
||||||
|
assistant call. Preserve adjacent context-invisible metadata. LearnHarness adapts
|
||||||
|
its visible custom markers to user-like entries, excludes UI-only markers, and
|
||||||
|
uses the active compaction's retained history rather than discarded messages.
|
||||||
|
|
||||||
|
Text, visible thinking, tool-call names/arguments use characters divided by four;
|
||||||
|
images receive the native fixed 1,200-token allowance. Encrypted signatures are
|
||||||
|
not measured or stripped. Whole-message boundaries can overshoot. This does
|
||||||
|
**not** guarantee a measured 20k tail or a fixed post-compaction request size.
|
||||||
|
|
||||||
|
Only cutoff selection is ported. LearnHarness retains its educational summary
|
||||||
|
prompt, plan continuity, persistence, threshold, scheduling, and authorization
|
||||||
|
lifecycle. It does not adopt Pi's coding summary or separate split-turn summary
|
||||||
|
calls. A cut that drops no active history does not launch a summarizer.
|
||||||
|
|
||||||
|
## Conversation meter
|
||||||
|
|
||||||
|
The numerator is the latest qualifying completed conversation response's native
|
||||||
|
Pi `input + output + cacheRead + cacheWrite`. Reasoning is already inside output;
|
||||||
|
`totalTokens` is not an override. Partial stream updates, errors, aborts, invalid
|
||||||
|
counts, and all-zero usage do not replace the last measurement. A successful
|
||||||
|
compaction marker invalidates earlier measurements: show `? / maximum` until a
|
||||||
|
new qualifying response completes. Summarizer usage never supplies this meter.
|
||||||
|
|
||||||
|
The denominator follows the selected model's catalog limit, independently of the
|
||||||
|
last numeric measurement. Changing models preserves that numerator. No catalog
|
||||||
|
limit means no invented denominator; no percentage display is added.
|
||||||
|
|
||||||
|
## Spending
|
||||||
|
|
||||||
|
`src/server/pricing.ts` uses Pi AI's full, key-independent catalog and public
|
||||||
|
`calculateCost` on a new usage object. The browser receives derived costs, not a
|
||||||
|
second calculator/rate table. Totals are estimates using the currently shipped
|
||||||
|
catalog, not provider invoices; catalog updates can reprice history.
|
||||||
|
|
||||||
|
Saved input is Pi-normalized **fresh input**, disjoint from cache buckets. Never
|
||||||
|
subtract cached reads from it again or migrate old logs as raw inclusive prompt
|
||||||
|
tokens. New captures preserve the one-hour subset of cache writes and individual
|
||||||
|
background-job calls. Older write-bearing records with a lost retention split,
|
||||||
|
and tier-priced multi-call jobs with only aggregate usage, remain unpriced when
|
||||||
|
the lost detail can change the answer. They are not reconstructed from totals.
|
||||||
|
Flat-rate historical jobs with sufficient counts remain priceable. Missing models
|
||||||
|
or required usage stay unpriced; ChatGPT subscriptions remain plan usage, not
|
||||||
|
API dollars. Summarizer costs continue to count toward session spending.
|
||||||
|
|
||||||
|
## Upstream attribution
|
||||||
|
|
||||||
|
The cutoff/estimator port adapts MIT-licensed code from
|
||||||
|
`@earendil-works/pi-coding-agent` 1.4.0, author Mario Zechner (published package
|
||||||
|
metadata). The inspected `dist/core/compaction/compaction.js` SHA-256 is
|
||||||
|
`f51e78ef1da66b5803fc91e185f07b968f8f989ab9326b7744958416aad3f6f6`.
|
||||||
|
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) Mario Zechner
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
# Desktop builds and releases
|
# Desktop builds and releases
|
||||||
|
|
||||||
|
For this fork’s Linux and Windows 0.5.2 release on Gitea, see [Gitea releases](gitea-release.md).
|
||||||
|
The three-platform GitHub process below documents the upstream reference.
|
||||||
|
|
||||||
## Platforms
|
## Platforms
|
||||||
|
|
||||||
| Platform | Build runner | Packages | In-app update |
|
| Platform | Build runner | Packages | In-app update |
|
||||||
|
|||||||
+5
-2
@@ -30,7 +30,6 @@ npm run desktop:dist # local signed/notarized macOS arm64 build (LH_NOTARY key
|
|||||||
npx tsx --env-file=.env.local scripts/import-notes.ts <dir> # bulk-import notes → graph
|
npx tsx --env-file=.env.local scripts/import-notes.ts <dir> # bulk-import notes → graph
|
||||||
npx tsx --env-file=.env.local scripts/backfill-graph.ts # wipe + re-extract every chat
|
npx tsx --env-file=.env.local scripts/backfill-graph.ts # wipe + re-extract every chat
|
||||||
npx tsx --env-file=.env.local scripts/replay-layout.ts # re-derive layout from the ledgers
|
npx tsx --env-file=.env.local scripts/replay-layout.ts # re-derive layout from the ledgers
|
||||||
npx tsx scripts/sync-pricing.ts # regenerate core/prices.ts tables
|
|
||||||
```
|
```
|
||||||
|
|
||||||
For Windows/Linux packages and the three-OS CI/release flow, see [`desktop-release.md`](desktop-release.md). Native renderer binaries are traced per platform, so release builds run on the target OS, not as cross-builds from macOS.
|
For Windows/Linux packages and the three-OS CI/release flow, see [`desktop-release.md`](desktop-release.md). Native renderer binaries are traced per platform, so release builds run on the target OS, not as cross-builds from macOS.
|
||||||
@@ -144,7 +143,7 @@ src/
|
|||||||
electron/ desktop launcher + security guard (spawns the standalone
|
electron/ desktop launcher + security guard (spawns the standalone
|
||||||
server; 1 IPC channel; LH_DATA_DIR + BYOK env)
|
server; 1 IPC channel; LH_DATA_DIR + BYOK env)
|
||||||
scripts/ build-server.mjs (packaging guards), import-notes,
|
scripts/ build-server.mjs (packaging guards), import-notes,
|
||||||
backfill-graph, replay-layout, sync-pricing
|
backfill-graph, replay-layout
|
||||||
data/ chats/ · graph/ (nodes/chains/sessions/coverage
|
data/ chats/ · graph/ (nodes/chains/sessions/coverage
|
||||||
ledgers + positions/sources/render-settings maps) ·
|
ledgers + positions/sources/render-settings maps) ·
|
||||||
jobs/ · settings.json — runtime state, git-ignored
|
jobs/ · settings.json — runtime state, git-ignored
|
||||||
@@ -152,6 +151,10 @@ data/ chats/ · graph/ (nodes/chains/sessions/coverage
|
|||||||
|
|
||||||
## Key mechanisms
|
## Key mechanisms
|
||||||
|
|
||||||
|
See [compaction and accounting](compaction-accounting.md) for the local Pi cutoff port,
|
||||||
|
measurement-only context meter, and server-side current-catalog pricing. There is no
|
||||||
|
pricing-generation step: Pi AI's catalog and public calculator are the authority.
|
||||||
|
|
||||||
**Client-side question tools.** `quiz` and `ask_user_question` have no server `execute`: the tool call streams to the browser, the learner answers in the dock, the client grades via `question-core` and returns the verdict with `addToolOutput` — which fires the SDK continuation so the teacher resumes mid-turn. Options are shuffled deterministically (seeded by toolCallId) so server and client renders match. A pending quiz persisted in the chat record is exactly what makes **resume mid-question free**.
|
**Client-side question tools.** `quiz` and `ask_user_question` have no server `execute`: the tool call streams to the browser, the learner answers in the dock, the client grades via `question-core` and returns the verdict with `addToolOutput` — which fires the SDK continuation so the teacher resumes mid-turn. Options are shuffled deterministically (seeded by toolCallId) so server and client renders match. A pending quiz persisted in the chat record is exactly what makes **resume mid-question free**.
|
||||||
|
|
||||||
**The formal plan tree (author-once).** The teacher authors the session's dependency tree with one schema-validated tool — `plan_init`, whose mermaid-subset input is parsed deterministically (line-numbered errors) and validated as a DAG. The tree persists in the chat record; progress is never mutated — `current`/`visited`/upcoming are **derived** from tagged quiz calls in the transcript, and the panel renders that derivation.
|
**The formal plan tree (author-once).** The teacher authors the session's dependency tree with one schema-validated tool — `plan_init`, whose mermaid-subset input is parsed deterministically (line-numbered errors) and validated as a DAG. The tree persists in the chat record; progress is never mutated — `current`/`visited`/upcoming are **derived** from tagged quiz calls in the transcript, and the panel renders that derivation.
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
# Gitea releases for this fork
|
||||||
|
|
||||||
|
The release destination is
|
||||||
|
https://server2.sorokin-oleg-edu.xyz/replicated/learnharness.
|
||||||
|
Version **0.5.2 targets Linux x64 and Windows x64**, with each platform built
|
||||||
|
and verified on its native operating system. The upstream GitHub workflow and three-platform packaging
|
||||||
|
configuration remain available as reference in `.github/workflows/release.yml`
|
||||||
|
and `electron-builder.yml`.
|
||||||
|
|
||||||
|
Official fork packages use `electron-builder.gitea.yml`, which inherits the
|
||||||
|
upstream packaging and replaces its update origin with:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://server2.sorokin-oleg-edu.xyz/replicated/learnharness/releases/download/latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Gitea's `latest` alias resolves the most recent published, non-prerelease release.
|
||||||
|
Drafts do not become updates. The repository and published downloads must be
|
||||||
|
public; no API credential is embedded in the application.
|
||||||
|
|
||||||
|
## Preparing Linux packages
|
||||||
|
|
||||||
|
Use Node **26**, matching upstream's release gates. Build from a clean checkout
|
||||||
|
of the intended release commit. Keep `.env*`, local keys, and application data out
|
||||||
|
of the build directory. Next can copy loaded environment files after tracing;
|
||||||
|
`build-server.mjs` refuses a payload containing them. A clean worktree avoids
|
||||||
|
loading these files in the first place.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm ci
|
||||||
|
npm run typecheck
|
||||||
|
npm test
|
||||||
|
npm run lint
|
||||||
|
ELECTRON_RUN_AS_NODE=1 npx electron --import tsx --test tests/pdf-native-render.test.ts tests/pdf-ops.test.ts tests/svg-native-render.test.ts tests/browser-service.test.ts
|
||||||
|
npm run desktop:release:linux
|
||||||
|
```
|
||||||
|
|
||||||
|
The release script builds the standalone server, initializes providers offline,
|
||||||
|
curates the payload, boots it under Electron's bundled Node, packages AppImage
|
||||||
|
and Debian installers, checks the packaged Gitea updater configuration, creates SHA-256 checksums, and verifies update metadata
|
||||||
|
against the actual installer bytes.
|
||||||
|
|
||||||
|
Run the packaged-runtime gates too:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
node scripts/smoke-provider-runtime.mjs --standalone dist/linux-unpacked/resources/server/standalone
|
||||||
|
node scripts/smoke-provider-runtime.mjs --standalone dist/linux-unpacked/resources/server/standalone --runtime dist/linux-unpacked/learnharness
|
||||||
|
xvfb-run -a node scripts/smoke-demo-runtime.mjs --standalone dist/linux-unpacked/resources/server/standalone --runtime dist/linux-unpacked/learnharness --browser
|
||||||
|
```
|
||||||
|
|
||||||
|
The Linux portion of 0.5.2 has these attachments:
|
||||||
|
|
||||||
|
- `LearnHarness-0.5.2-x86_64.AppImage`
|
||||||
|
- `LearnHarness-0.5.2-amd64.deb`
|
||||||
|
- `latest-linux.yml`
|
||||||
|
- `SHA256SUMS`
|
||||||
|
|
||||||
|
## Preparing Windows packages
|
||||||
|
|
||||||
|
Use a clean checkout of the same `v0.5.2` tag on Windows x64 with Node 26 and Git
|
||||||
|
for Windows. Keep build tools and logs outside the checkout. The Windows
|
||||||
|
release checkout is `~/Documents/Gitea/learnharness-release-0.5.2`, linked to the
|
||||||
|
existing repository in `~/Documents/Gitea/learnharness`.
|
||||||
|
|
||||||
|
In PowerShell, stop after any failed command:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm.cmd ci
|
||||||
|
$env:NODE_ENV = 'test'
|
||||||
|
$env:ELECTRON_RUN_AS_NODE = '1'
|
||||||
|
node node_modules/electron/cli.js --import tsx --test tests/pdf-native-render.test.ts tests/pdf-ops.test.ts tests/svg-native-render.test.ts tests/browser-service.test.ts
|
||||||
|
Remove-Item Env:ELECTRON_RUN_AS_NODE
|
||||||
|
node --import tsx --test tests/i18n.test.tsx
|
||||||
|
Remove-Item Env:NODE_ENV
|
||||||
|
node node_modules/next/dist/bin/next build
|
||||||
|
node scripts/smoke-provider-runtime.mjs
|
||||||
|
node scripts/build-server.mjs
|
||||||
|
node scripts/smoke-server.mjs --electron
|
||||||
|
node node_modules/electron-builder/out/cli/cli.js --config electron-builder.gitea.yml --win --x64 --publish never
|
||||||
|
node scripts/gitea-release.mjs verify-package dist/win-unpacked
|
||||||
|
node scripts/smoke-provider-runtime.mjs --standalone dist/win-unpacked/resources/server/standalone
|
||||||
|
node scripts/smoke-provider-runtime.mjs --standalone dist/win-unpacked/resources/server/standalone --runtime dist/win-unpacked/LearnHarness.exe
|
||||||
|
node scripts/smoke-demo-runtime.mjs --standalone dist/win-unpacked/resources/server/standalone --runtime dist/win-unpacked/LearnHarness.exe --browser
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows contributes three attachments:
|
||||||
|
|
||||||
|
- `LearnHarness-0.5.2-x64-setup.exe`
|
||||||
|
- `LearnHarness-0.5.2-x64-setup.exe.blockmap`
|
||||||
|
- `latest.yml`
|
||||||
|
|
||||||
|
The combined release has **seven attachments**, including one `SHA256SUMS`
|
||||||
|
covering all six other files. Verify that `latest.yml` names the Windows
|
||||||
|
installer exactly, reports version `0.5.2`, and matches the installer's actual
|
||||||
|
size and SHA-512. Its gzip blockmap must cover the installer and match every
|
||||||
|
block checksum. Confirm the packaged `resources/app-update.yml` uses the Gitea
|
||||||
|
generic provider. Windows builds follow upstream's unsigned, per-user installer
|
||||||
|
configuration.
|
||||||
|
|
||||||
|
## Preparing a Linux-only Gitea draft
|
||||||
|
|
||||||
|
Commit and push the release source, then push its exact annotated tag:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git tag -a v0.5.2 -m 'LearnHarness 0.5.2'
|
||||||
|
git push gitea main v0.5.2
|
||||||
|
```
|
||||||
|
|
||||||
|
Supply `GITEA_TOKEN` only to the upload command. A token file may remain outside
|
||||||
|
the clean build checkout:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
node --env-file=/path/to/private/release.env scripts/gitea-release.mjs upload dist
|
||||||
|
```
|
||||||
|
|
||||||
|
The tool requires the tag to exist in Gitea, creates or reuses one draft at that
|
||||||
|
tag, uploads missing assets, and downloads every attachment to verify its actual
|
||||||
|
SHA-256 against the local verified build. Existing mismatched attachments and
|
||||||
|
published releases are refused. It never publishes or changes repository
|
||||||
|
visibility. Release notes come from `docs/release-0.5.2.md`.
|
||||||
|
|
||||||
|
This uploader and its remote verifier enforce a **Linux-only** inventory. They
|
||||||
|
will refuse a combined Linux/Windows draft. For the combined 0.5.2 release, add
|
||||||
|
the verified Windows attachments to the existing draft and replace `SHA256SUMS`
|
||||||
|
with the combined manifest. Keep the Linux installers and feed unchanged. Check
|
||||||
|
all seven attachment names and sizes against the verified local builds. For
|
||||||
|
the owner-approved 0.5.2 release, local installer/feed/blockmap validation and
|
||||||
|
the combined checksum manifest are completed before upload. Confirm successful
|
||||||
|
upload responses and publish immediately afterward; do not download attachments
|
||||||
|
again or run post-upload hash checks. Do not publish until both platforms'
|
||||||
|
local checks pass.
|
||||||
|
|
||||||
|
A separate verification can be repeated without changing the draft:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
node --env-file=/path/to/private/release.env scripts/gitea-release.mjs verify-remote dist
|
||||||
|
```
|
||||||
|
|
||||||
|
Review the verified draft before publication. Only after publication will the
|
||||||
|
public `latest` feed and in-app automatic updates expose this version.
|
||||||
|
|
||||||
|
## Gitea Actions
|
||||||
|
|
||||||
|
`.gitea/workflows/release.yml` provides the same Linux gates through a manual
|
||||||
|
workflow with an existing release tag as input. It requires a native Linux x64
|
||||||
|
runner labeled `ubuntu-24.04`, the label used by upstream's Linux release job,
|
||||||
|
plus the native Electron dependencies and `xvfb-run`. There were no runners
|
||||||
|
registered when 0.5.2 was prepared, so its packages are built locally. A tag push
|
||||||
|
does not queue a job that cannot run.
|
||||||
|
|
||||||
|
The workflow uses the repository's ephemeral `GITEA_TOKEN` with release-write
|
||||||
|
permission only in the final upload step. It checks out the requested tag without
|
||||||
|
persisting credentials and leaves the result as a verified draft. Public pull
|
||||||
|
requests cannot trigger this publishing workflow. This workflow remains
|
||||||
|
Linux-only; the combined 0.5.2 draft was assembled from local native builds.
|
||||||
|
Windows has its own standalone payload and smoke checks, as described above.
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Language support
|
||||||
|
|
||||||
|
LearnHarness supports English and Russian. Settings → General contains independent
|
||||||
|
interface and speech recognition language selectors. The microphone also has a
|
||||||
|
speech language selector. Each offers System default, English, and Русский.
|
||||||
|
|
||||||
|
System default uses the preferred browser language (the operating system's
|
||||||
|
preferred language in the desktop app); Russian locales select Russian and other
|
||||||
|
locales select English. Explicit choices persist in `settings.json` as
|
||||||
|
`general.language` and `general.sttLanguage`. Removing a field restores automatic
|
||||||
|
selection. Changing one preference preserves the other general settings.
|
||||||
|
|
||||||
|
The interface changes immediately, including accessibility labels and the desktop
|
||||||
|
menu. New AI teaching, questions, plans, practice, graph descriptions, research,
|
||||||
|
figures, and demos use the interface language. Existing conversations and imported
|
||||||
|
source material retain their authored text. A session retains the language of its
|
||||||
|
latest foreground turn so automatic follow-ups can use the same language after a
|
||||||
|
restart; an explicit app language preference takes precedence.
|
||||||
|
|
||||||
|
Speech recognition uses Deepgram Nova-3 with an explicit `language=en` or
|
||||||
|
`language=ru`. A recording captures the selected language when it starts and keeps
|
||||||
|
that language through reconnections. The selector is disabled while recording;
|
||||||
|
changes elsewhere take effect on the next recording. Audio capture, deferred
|
||||||
|
transcript insertion, stopping, and cancellation retain their existing behavior.
|
||||||
|
|
||||||
|
## Maintaining translations
|
||||||
|
|
||||||
|
The pure localization functions live in `src/core/i18n.ts` and are exported through
|
||||||
|
the core barrel. English and Russian catalogs are in `src/core/locales/en.json` and
|
||||||
|
`ru.json`. English source phrases are message keys. Add every new app-owned message
|
||||||
|
to both catalogs, use named interpolation parameters, and use plural entries with
|
||||||
|
`Intl.PluralRules` for counts. Russian entries may use `one`, `few`, `many`, and
|
||||||
|
`other` forms. Keep code, provider/model names, identifiers, and user-authored text
|
||||||
|
out of translation lookups.
|
||||||
|
|
||||||
|
React components use `useI18n()` from `components/i18n.tsx`. The root provider gets
|
||||||
|
server-resolved initial settings and locale to preserve hydration. General-setting
|
||||||
|
writes share a serialized read-modify-write path; other surfaces use the existing
|
||||||
|
settings-saved signal. Server and agent code receive an explicit language rather
|
||||||
|
than depending on a mutable global translation state.
|
||||||
|
|
||||||
|
Some existing protocol errors arrive preformatted in English. Localization can
|
||||||
|
recognize cataloged application templates at the display boundary, preserving the
|
||||||
|
canonical error values used for classification and any third-party diagnostics.
|
||||||
|
Raw technical instructions, code, and user overrides remain editable source data.
|
||||||
|
|
||||||
|
The desktop shell has its own small catalog in `electron/language.cjs`, packaged
|
||||||
|
with the shell. Its narrow `lh:language` channel accepts only `en` and `ru` from a
|
||||||
|
trusted application window. Russian prose uses Noto Sans because Source Sans 3
|
||||||
|
has no Cyrillic subset. Explicit font choices continue to take precedence.
|
||||||
|
|
||||||
|
`tests/i18n.test.tsx` covers preference validation, automatic selection, independent
|
||||||
|
STT requests, catalog parity and interpolation, Russian plural forms, SSR locale
|
||||||
|
isolation, translated dialogs and exports, agent instructions, and native labels.
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# LearnHarness 0.4.0
|
||||||
|
|
||||||
|
## English
|
||||||
|
|
||||||
|
LearnHarness now supports English and Russian throughout the interface and new
|
||||||
|
AI learning content. Switch languages in **Settings → General**. The interface
|
||||||
|
language and speech recognition language are independent and persist across
|
||||||
|
restarts; System default follows the browser or operating system, using English
|
||||||
|
for unsupported languages.
|
||||||
|
|
||||||
|
- Russian translations cover lessons, practice, the knowledge graph, setup,
|
||||||
|
settings, dialogs, tooltips, accessibility labels, notifications, exports,
|
||||||
|
desktop menus, and application error messages.
|
||||||
|
- New teaching, questions, plans, practice, graph descriptions, research,
|
||||||
|
diagrams, and interactive demos follow the interface language.
|
||||||
|
- Choose English or Russian speech recognition next to the microphone or in
|
||||||
|
General settings. A recording retains its language through reconnections;
|
||||||
|
changing the preference affects the next recording.
|
||||||
|
- Russian text uses a Cyrillic-capable reading font, correct plural forms,
|
||||||
|
localized numbers, and filenames that preserve Cyrillic characters.
|
||||||
|
- Language changes preserve other settings. Existing conversations and imported
|
||||||
|
learning material retain their original text.
|
||||||
|
|
||||||
|
This Gitea release includes **Linux x64** AppImage and Debian packages and a
|
||||||
|
**Windows x64** installer. Both platforms were built and checked on their native
|
||||||
|
operating systems. Automatic updates use this fork's public Gitea releases.
|
||||||
|
The Windows installer is unsigned, matching upstream's Windows packaging.
|
||||||
|
|
||||||
|
To use live dictation, configure a Deepgram key under **Settings → Providers**.
|
||||||
|
To start AI sessions, connect an AI provider and select the required model tiers.
|
||||||
|
|
||||||
|
## Русский
|
||||||
|
|
||||||
|
LearnHarness теперь поддерживает английский и русский языки во всём интерфейсе
|
||||||
|
и в новых учебных материалах, создаваемых ИИ. Язык можно выбрать в разделе
|
||||||
|
**Настройки → Общие**. Язык интерфейса и язык распознавания речи выбираются
|
||||||
|
независимо и сохраняются после перезапуска. В режиме «Язык системы» используется
|
||||||
|
язык браузера или операционной системы; для неподдерживаемых языков — английский.
|
||||||
|
|
||||||
|
- Переведены занятия, практика, граф знаний, первоначальная настройка,
|
||||||
|
параметры приложения, диалоги, подсказки, подписи для программ чтения с экрана,
|
||||||
|
уведомления, экспорт, меню настольного приложения и сообщения об ошибках.
|
||||||
|
- Новые объяснения, вопросы, планы, практические задачи, описания в графе,
|
||||||
|
исследования, диаграммы и интерактивные демонстрации используют язык интерфейса.
|
||||||
|
- Английский или русский язык распознавания речи можно выбрать рядом с микрофоном
|
||||||
|
или в общих настройках. Запись сохраняет выбранный язык при восстановлении
|
||||||
|
соединения; изменение настройки применяется к следующей записи.
|
||||||
|
- Для русского текста добавлен шрифт с кириллицей, правильные формы множественного
|
||||||
|
числа, локализованное отображение чисел и поддержка кириллицы в именах файлов.
|
||||||
|
- Смена языка сохраняет остальные настройки. Существующие разговоры и
|
||||||
|
импортированные учебные материалы остаются на исходном языке.
|
||||||
|
|
||||||
|
Этот выпуск в Gitea включает пакеты AppImage и Debian для **Linux x64** и
|
||||||
|
установщик для **Windows x64**. Сборка и проверка каждой версии выполнены в её
|
||||||
|
операционной системе. Автоматические обновления используют публичные выпуски
|
||||||
|
этого форка в Gitea. Установщик Windows не подписан — как и в исходном проекте.
|
||||||
|
|
||||||
|
Для диктовки добавьте ключ Deepgram в разделе **Настройки → Провайдеры**.
|
||||||
|
Для занятий с ИИ подключите провайдера и выберите модели для всех нужных уровней.
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# LearnHarness 0.5.0
|
||||||
|
|
||||||
|
## English
|
||||||
|
|
||||||
|
This release adds ChatGPT Fast mode and improves the language controls and
|
||||||
|
Russian settings layout introduced in 0.4.0.
|
||||||
|
|
||||||
|
- Interface-language and speech-recognition selectors in **Settings → General**,
|
||||||
|
plus the quick selector beside the microphone, now use the app's styled menus.
|
||||||
|
The menus include selected-item checkmarks and keyboard navigation; Escape
|
||||||
|
closes the menu and restores focus to its trigger. Language preferences stay
|
||||||
|
independent, and STT selection remains locked while recording.
|
||||||
|
- **Quizzing** keeps its full Russian labels. The label and segmented control
|
||||||
|
share a row when space permits. Narrower panels stack the control beneath the
|
||||||
|
hint with matching left alignment and enough height for wrapped text.
|
||||||
|
- **Settings → Agent** now has a **⚡ Fast** toggle for each Strong, Cheap, and
|
||||||
|
Vision model tier. Fast mode is **off by default** and initially available only
|
||||||
|
for **ChatGPT** connections. It requests priority processing and may consume
|
||||||
|
more ChatGPT plan allowance; availability depends on the model and account.
|
||||||
|
- Fast preferences take effect on the **next request**, including existing
|
||||||
|
ChatGPT conversations and newly started background work in the corresponding
|
||||||
|
tier. The selected model and reasoning effort are preserved. Selecting another
|
||||||
|
provider turns Fast off for that tier; other providers show it as unavailable.
|
||||||
|
- Unsupported Fast requests produce actionable, localized errors. Connections
|
||||||
|
keep their existing account, background-consent, and no-API-key-fallback rules.
|
||||||
|
|
||||||
|
Packages: **Linux x64** AppImage and Debian, and a **Windows x64** installer.
|
||||||
|
Both platforms use this fork's public Gitea releases for automatic updates.
|
||||||
|
Windows packaging remains unsigned, matching upstream.
|
||||||
|
|
||||||
|
## Русский
|
||||||
|
|
||||||
|
В этом выпуске добавлен быстрый режим ChatGPT и улучшены языковые настройки
|
||||||
|
и их отображение на русском языке, появившиеся в версии 0.4.0.
|
||||||
|
|
||||||
|
- Выбор языка интерфейса и распознавания речи в разделе **Настройки → Общие**,
|
||||||
|
а также быстрый выбор языка рядом с микрофоном теперь используют меню в стиле
|
||||||
|
приложения. Выбранный пункт отмечен галочкой, поддерживается управление
|
||||||
|
клавиатурой. Escape закрывает меню и возвращает фокус на кнопку. Языковые
|
||||||
|
настройки остаются независимыми; язык распознавания нельзя менять во время
|
||||||
|
записи.
|
||||||
|
- В разделе **Проверка понимания** сохранены полные русские подписи. Если места
|
||||||
|
достаточно, заголовок и переключатель расположены в одной строке. В узких
|
||||||
|
панелях переключатель размещается под пояснением с выравниванием по левому
|
||||||
|
краю; высота кнопок учитывает перенос текста.
|
||||||
|
- В разделе **Настройки → Агент** появился переключатель **⚡ Быстро** для каждого
|
||||||
|
уровня моделей: основной, экономичной и с поддержкой изображений. Быстрый
|
||||||
|
режим **отключён по умолчанию** и пока доступен только для подключений
|
||||||
|
**ChatGPT**. Он запрашивает приоритетную обработку и может быстрее расходовать
|
||||||
|
лимит ChatGPT. Доступность зависит от модели и учётной записи.
|
||||||
|
- Настройка применяется к **следующему запросу**, в том числе в существующих
|
||||||
|
занятиях с ChatGPT и в новых фоновых задачах соответствующего уровня.
|
||||||
|
Выбранная модель и уровень рассуждений сохраняются. При выборе другого
|
||||||
|
провайдера быстрый режим для этого уровня отключается; для остальных
|
||||||
|
провайдеров переключатель недоступен.
|
||||||
|
- Если быстрый режим не поддерживается, приложение показывает понятную ошибку
|
||||||
|
на выбранном языке. Сохраняются выбранная учётная запись, разрешения на
|
||||||
|
фоновую работу и запрет автоматического перехода на API-ключ.
|
||||||
|
|
||||||
|
Пакеты: AppImage и Debian для **Linux x64**, установщик для **Windows x64**.
|
||||||
|
Обе версии используют публичные выпуски этого форка в Gitea для автоматических
|
||||||
|
обновлений. Установщик Windows не подписан, как и в исходном проекте.
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# LearnHarness 0.5.1
|
||||||
|
|
||||||
|
## English
|
||||||
|
|
||||||
|
This release adds multilingual speech recognition and simplifies the STT picker
|
||||||
|
beside the microphone.
|
||||||
|
|
||||||
|
- **Settings → General → Speech recognition language** now includes
|
||||||
|
**Multilingual**. It uses Deepgram Nova-3's `language=multi` mode for speech
|
||||||
|
that switches between supported languages, including Russian and English.
|
||||||
|
The preference is saved and applies to the next recording, including any
|
||||||
|
reconnects during that recording.
|
||||||
|
- The chat STT picker now offers **English**, **Русский**, and **Multilingual**.
|
||||||
|
The compact button displays **Multi** when multilingual recognition is selected.
|
||||||
|
The **Auto · LANG** entry has been removed from this picker.
|
||||||
|
- **System default** remains available in General settings. When it is active,
|
||||||
|
the chat picker displays the resolved English or Russian language. Opening
|
||||||
|
the picker preserves System default; choosing a language saves that explicit
|
||||||
|
preference, even when it matches the current system language.
|
||||||
|
- Interface and STT languages remain independently selectable. Existing
|
||||||
|
preferences and system defaults are preserved, and both STT controls remain
|
||||||
|
locked while recording. The menus retain keyboard navigation and selected-item
|
||||||
|
checkmarks.
|
||||||
|
|
||||||
|
Packages: **Linux x64** AppImage and Debian, and a **Windows x64** installer.
|
||||||
|
Both platforms use this fork's public Gitea releases for automatic updates.
|
||||||
|
Windows packaging remains unsigned, matching the previous release.
|
||||||
|
|
||||||
|
## Русский
|
||||||
|
|
||||||
|
В этом выпуске добавлено многоязычное распознавание речи и упрощён выбор языка
|
||||||
|
рядом с микрофоном.
|
||||||
|
|
||||||
|
- В разделе **Настройки → Общие → Язык распознавания речи** появился вариант
|
||||||
|
**Несколько языков**. Он включает режим `language=multi` в Deepgram Nova-3
|
||||||
|
для речи с переключением между поддерживаемыми языками, в том числе русским
|
||||||
|
и английским. Выбор сохраняется и применяется к следующей записи, включая
|
||||||
|
повторные подключения во время этой записи.
|
||||||
|
- В меню языка распознавания рядом с микрофоном теперь доступны **English**,
|
||||||
|
**Русский** и **Несколько языков**. При многоязычном распознавании компактная
|
||||||
|
кнопка показывает **Мульти**. Пункт **Авто · LANG** из этого меню удалён.
|
||||||
|
- В общих настройках по-прежнему доступен вариант **Язык системы**. Если он
|
||||||
|
выбран, кнопка рядом с микрофоном показывает фактически используемый язык:
|
||||||
|
английский или русский. Открытие меню не меняет настройку. Выбор языка
|
||||||
|
сохраняет его явно, даже если он совпадает с текущим языком системы.
|
||||||
|
- Язык интерфейса и язык распознавания остаются независимыми. Сохраняются
|
||||||
|
прежние предпочтения и выбор языка системы. Оба переключателя языка
|
||||||
|
распознавания заблокированы во время записи. Меню поддерживают управление
|
||||||
|
клавиатурой и отмечают выбранный пункт галочкой.
|
||||||
|
|
||||||
|
Пакеты: AppImage и Debian для **Linux x64**, установщик для **Windows x64**.
|
||||||
|
Обе версии используют публичные выпуски этого форка в Gitea для автоматических
|
||||||
|
обновлений. Установщик Windows не подписан, как и в предыдущем выпуске.
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# LearnHarness 0.5.2
|
||||||
|
|
||||||
|
## English
|
||||||
|
|
||||||
|
This release adds preferred speech-recognition languages and fixes the model
|
||||||
|
settings layout.
|
||||||
|
|
||||||
|
- **Settings → General → Preferred languages** offers **English** and **Русский**
|
||||||
|
checkboxes below the label and explanation. Select both with **Multilingual**
|
||||||
|
to guide recognition toward Russian and English in the same recording.
|
||||||
|
Preferred languages are hints, so other supported languages may still be
|
||||||
|
recognized. Leaving both unchecked keeps automatic detection.
|
||||||
|
- Preferences are saved independently of interface language and STT mode.
|
||||||
|
They remain saved when a single-language mode is selected, but are sent only
|
||||||
|
with Multilingual. The controls lock while saving or recording, and a failed
|
||||||
|
save preserves the previous choice and shows a localized error.
|
||||||
|
- Each recording keeps its language and preferred-language hints through any
|
||||||
|
reconnects. Later preference changes apply to the next recording.
|
||||||
|
- **Settings → Agent** now gives model-tier descriptions a flexible text column.
|
||||||
|
Model, reasoning, and Fast controls stay together and align across tiers.
|
||||||
|
Narrow panels place the complete control group below the description,
|
||||||
|
aligned left. Long model names keep their existing ellipsis, and background-use
|
||||||
|
permission buttons occupy a separate line.
|
||||||
|
|
||||||
|
Packages: **Linux x64** AppImage and Debian, and a **Windows x64** installer.
|
||||||
|
Both platforms use this fork's public Gitea releases for automatic updates.
|
||||||
|
Windows packaging remains unsigned, matching previous releases.
|
||||||
|
|
||||||
|
## Русский
|
||||||
|
|
||||||
|
В этом выпуске добавлен выбор предпочитаемых языков распознавания речи
|
||||||
|
и исправлено расположение элементов в настройках моделей.
|
||||||
|
|
||||||
|
- В разделе **Настройки → Общие → Предпочитаемые языки** появились флажки
|
||||||
|
**English** и **Русский**, расположенные под заголовком и пояснением.
|
||||||
|
Выберите оба в режиме **Несколько языков**, чтобы отдавать приоритет русскому
|
||||||
|
и английскому в одной записи. Это подсказки для распознавания: другие
|
||||||
|
поддерживаемые языки тоже могут быть распознаны. Если оба флажка сняты,
|
||||||
|
используется автоматическое определение языка.
|
||||||
|
- Предпочтения сохраняются независимо от языка интерфейса и режима распознавания.
|
||||||
|
При выборе одного языка они сохраняются, но отправляются только в режиме
|
||||||
|
«Несколько языков». Флажки заблокированы во время сохранения и записи.
|
||||||
|
Если сохранить настройку не удалось, прежний выбор сохраняется, а приложение
|
||||||
|
показывает ошибку на выбранном языке.
|
||||||
|
- Язык и подсказки фиксируются на время записи, включая повторные подключения.
|
||||||
|
Последующие изменения применяются к следующей записи.
|
||||||
|
- В разделе **Настройки → Агент** описания уровней моделей занимают гибкую
|
||||||
|
текстовую колонку. Выбор модели, уровня рассуждения и быстрый режим остаются
|
||||||
|
вместе и выровнены между уровнями. В узких панелях вся группа располагается
|
||||||
|
под описанием с выравниванием по левому краю. Длинные названия моделей
|
||||||
|
по-прежнему сокращаются многоточием, а кнопки разрешения фоновой работы
|
||||||
|
размещаются на отдельной строке.
|
||||||
|
|
||||||
|
Пакеты: AppImage и Debian для **Linux x64**, установщик для **Windows x64**.
|
||||||
|
Обе версии используют публичные выпуски этого форка в Gitea для автоматических
|
||||||
|
обновлений. Установщик Windows не подписан, как и в предыдущих выпусках.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Official packages for this fork. Upstream packaging stays in electron-builder.yml.
|
||||||
|
extends: ./electron-builder.yml
|
||||||
|
publish:
|
||||||
|
# Use an object: electron-builder concatenates inherited publish arrays.
|
||||||
|
provider: generic
|
||||||
|
# Gitea resolves the latest published, non-prerelease release here.
|
||||||
|
# Drafts remain private and do not become an update before publication.
|
||||||
|
url: https://server2.sorokin-oleg-edu.xyz/replicated/learnharness/releases/download/latest
|
||||||
@@ -81,7 +81,12 @@ files:
|
|||||||
- node_modules/universalify/**/*
|
- node_modules/universalify/**/*
|
||||||
- node_modules/ms/**/*
|
- node_modules/ms/**/*
|
||||||
- node_modules/argparse/**/*
|
- node_modules/argparse/**/*
|
||||||
|
# Main-process ID-token signature verification (no bundled OAuth implementation).
|
||||||
|
- node_modules/jose/**/*
|
||||||
extraResources:
|
extraResources:
|
||||||
|
# Attribution/license for the bounded local Pi cutoff-selector port.
|
||||||
|
- from: docs/compaction-accounting.md
|
||||||
|
to: licenses/pi-compaction.md
|
||||||
# Copy from the PARENT dir (.next), not .next/standalone itself:
|
# Copy from the PARENT dir (.next), not .next/standalone itself:
|
||||||
# electron-builder hard-excludes a copy's root node_modules and the
|
# electron-builder hard-excludes a copy's root node_modules and the
|
||||||
# default no-filter pattern is a literal path match (empirically both
|
# default no-filter pattern is a literal path match (empirically both
|
||||||
@@ -95,6 +100,11 @@ extraResources:
|
|||||||
filter:
|
filter:
|
||||||
- "standalone/**/*"
|
- "standalone/**/*"
|
||||||
mac:
|
mac:
|
||||||
|
# The backend's --require runs before Next loads. Keep this resource
|
||||||
|
# outside app.asar so early Node-mode loading needs no ASAR support.
|
||||||
|
extraResources:
|
||||||
|
- from: electron/server-title-guard.cjs
|
||||||
|
to: server-title-guard.cjs
|
||||||
icon: build/icon.icon
|
icon: build/icon.icon
|
||||||
# zip rides next to dmg (flipped 2026-10-08): Squirrel.Mac — Electron's
|
# zip rides next to dmg (flipped 2026-10-08): Squirrel.Mac — Electron's
|
||||||
# macOS auto-updater — installs from ZIP and cannot read DMGs. DMG is
|
# macOS auto-updater — installs from ZIP and cannot read DMGs. DMG is
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"use strict";
|
||||||
|
const { publicError } = require("./chatgpt.cjs");
|
||||||
|
|
||||||
|
/** Inherited Node child IPC, not a listening HTTP/socket token service. */
|
||||||
|
function attachChatGPTChild(child, connection) {
|
||||||
|
const send = (value) => {
|
||||||
|
if (child.connected) child.send({ channel: "lh:chatgpt", ...value }, () => {});
|
||||||
|
};
|
||||||
|
const listener = async (message) => {
|
||||||
|
if (!message || message.channel !== "lh:chatgpt" || typeof message.id !== "string" || message.id.length > 100) return;
|
||||||
|
try {
|
||||||
|
let value;
|
||||||
|
if (message.op === "status") value = { status: await connection.status(), ...connection.stamp() };
|
||||||
|
else if (message.op === "models") value = await connection.models();
|
||||||
|
else if (message.op === "lease" && typeof message.background === "boolean") value = await connection.lease(message.background, message);
|
||||||
|
else throw new Error("Invalid private operation");
|
||||||
|
send({ id: message.id, op: message.op, ok: true, value });
|
||||||
|
} catch (err) { send({ id: message.id, op: message.op, ok: false, stamp: connection.stamp(), error: publicError(err) }); }
|
||||||
|
};
|
||||||
|
child.on("message", listener);
|
||||||
|
return { send, dispose: () => child.removeListener("message", listener) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sender must be the trusted window's main frame at its exact app origin. */
|
||||||
|
function trustedSender(event, origin, BrowserWindow) {
|
||||||
|
const win = BrowserWindow.fromWebContents(event.sender);
|
||||||
|
try {
|
||||||
|
return !!win && event.senderFrame === event.sender.mainFrame && new URL(event.senderFrame.url).origin === origin;
|
||||||
|
} catch { return false; }
|
||||||
|
}
|
||||||
|
async function chatGPTControl(command, connection) {
|
||||||
|
try {
|
||||||
|
if (command?.op === "status") return await connection.status();
|
||||||
|
if (command?.op === "login" && (command.id === undefined || typeof command.id === "string")) return await connection.login(command.id);
|
||||||
|
if (command?.op === "cancel") return connection.cancelLogin();
|
||||||
|
if (command?.op === "select" && typeof command.id === "string") return await connection.select(command.id);
|
||||||
|
if (command?.op === "disconnect" && typeof command.id === "string") return await connection.disconnect(command.id);
|
||||||
|
if (command?.op === "logout" && typeof command.id === "string") return await connection.logout(command.id);
|
||||||
|
if (command?.op === "consent" && typeof command.enabled === "boolean") return await connection.consent(command.enabled);
|
||||||
|
return { error: "Unsupported ChatGPT control." };
|
||||||
|
} catch (err) { return { error: publicError(err) }; }
|
||||||
|
}
|
||||||
|
module.exports = { attachChatGPTChild, trustedSender, chatGPTControl };
|
||||||
@@ -0,0 +1,542 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
// LearnHarness-owned public-client OAuth. Never import pi's bundled login.
|
||||||
|
// This module is main-process-only; dependencies are injectable for scratch tests.
|
||||||
|
const crypto = require("node:crypto");
|
||||||
|
const http = require("node:http");
|
||||||
|
const fs = require("node:fs/promises");
|
||||||
|
const path = require("node:path");
|
||||||
|
|
||||||
|
const ISSUER = "https://auth.openai.com";
|
||||||
|
const RESOURCE = "https://api.openai.com/v1";
|
||||||
|
const AUTHORIZE = `${ISSUER}/api/accounts/authorize`;
|
||||||
|
const TOKEN = `${ISSUER}/api/accounts/oauth/token`;
|
||||||
|
const JWKS = `${ISSUER}/.well-known/jwks.json`;
|
||||||
|
const SCOPES = ["openid", "profile", "email", "offline_access", "resource.invoke", "chatgpt.tokens.use.direct"];
|
||||||
|
const REVOCATION_WARNING = "Account disconnected locally. Remote revocation could not be confirmed.";
|
||||||
|
const random = () => crypto.randomBytes(32).toString("base64url");
|
||||||
|
const fail = (code) => Object.assign(new Error(code), { code });
|
||||||
|
const publicError = (err) => ({
|
||||||
|
cancelled: "ChatGPT sign-in cancelled.",
|
||||||
|
timeout: "ChatGPT sign-in timed out. Try again.",
|
||||||
|
storage: "ChatGPT credentials could not be read or saved. Check local file access, then reconnect before making requests.",
|
||||||
|
identity: "ChatGPT sign-in could not be verified. Select the intended saved account and try again.",
|
||||||
|
scope: "ChatGPT plan permission is missing. Reconnect and authorize ChatGPT plan use.",
|
||||||
|
disconnected: "This ChatGPT connection is disconnected. Reconnect in Providers.",
|
||||||
|
background: "ChatGPT background use is off in this app. Enable Allow background use for all ChatGPT accounts in Providers, or explicitly select an API-key model for that workload.",
|
||||||
|
catalog: "The ChatGPT account model catalog is unavailable. Retry the connection; no API-key fallback was used.",
|
||||||
|
expired: "The ChatGPT session expired. Reconnect in Providers.",
|
||||||
|
network: "ChatGPT could not be reached. Check your connection and try again.",
|
||||||
|
pending: "A ChatGPT sign-in is already pending.",
|
||||||
|
unavailable: "ChatGPT sign-in is unavailable in browser-only mode.",
|
||||||
|
}[err?.code] || "ChatGPT sign-in failed. Try again; no API-key fallback was used.");
|
||||||
|
|
||||||
|
const nonempty = (v) => typeof v === "string" && v.length > 0 && !/[\x00-\x20\x7f]/.test(v);
|
||||||
|
const planScopes = (tokens) => !!tokens?.accessToken && SCOPES.every((scope) => tokens.scopes.includes(scope));
|
||||||
|
/** Reject the entire credential record when its schema is invalid. */
|
||||||
|
function validateState(state) {
|
||||||
|
if (!state || state.version !== 1 || !nonempty(state.hostId) || !Array.isArray(state.registrations) || !(state.activeId === null || nonempty(state.activeId))) throw fail("storage");
|
||||||
|
if (Object.hasOwn(state, "allowBackgroundUse") && typeof state.allowBackgroundUse !== "boolean") throw fail("storage");
|
||||||
|
const ids = new Set(); const clients = new Set();
|
||||||
|
for (const r of state.registrations) {
|
||||||
|
if (!r || !nonempty(r.id) || ids.has(r.id) || !nonempty(r.clientId) || r.clientId === "dynamic_agent_client" || clients.has(r.clientId) || r.issuer !== ISSUER || !nonempty(r.subject) || (Object.hasOwn(r, "background") && typeof r.background !== "boolean") || (r.email !== undefined && typeof r.email !== "string") || (Object.hasOwn(r, "reconnectRequired") && r.reconnectRequired !== true)) throw fail("storage");
|
||||||
|
ids.add(r.id); clients.add(r.clientId);
|
||||||
|
if (r.tokens !== undefined) {
|
||||||
|
const t = r.tokens;
|
||||||
|
if (!t || !nonempty(t.idToken) || !Array.isArray(t.scopes) || t.scopes.some((s) => !nonempty(s)) || !Number.isFinite(t.expiresAt) || t.expiresAt < 0 || (t.accessToken !== undefined && (!nonempty(t.accessToken) || t.accessToken.startsWith("sk-"))) || (t.refreshToken !== undefined && !nonempty(t.refreshToken))) throw fail("storage");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (state.activeId !== null && !ids.has(state.activeId)) throw fail("storage");
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Private, unencrypted JSON outside learning data. No OS credential-store access. */
|
||||||
|
function fileStore(file) {
|
||||||
|
return {
|
||||||
|
async read() {
|
||||||
|
try {
|
||||||
|
return validateState(JSON.parse(await fs.readFile(file, "utf8")));
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === "ENOENT") return null;
|
||||||
|
throw fail("storage");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async write(state) {
|
||||||
|
validateState(state);
|
||||||
|
const temp = `${file}.${crypto.randomUUID()}.tmp`;
|
||||||
|
let handle;
|
||||||
|
try {
|
||||||
|
await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
||||||
|
handle = await fs.open(temp, "wx", 0o600);
|
||||||
|
await handle.writeFile(`${JSON.stringify(state)}\n`);
|
||||||
|
await handle.sync();
|
||||||
|
await handle.close();
|
||||||
|
handle = null;
|
||||||
|
await fs.rename(temp, file);
|
||||||
|
// Directory fsync is supported on Unix; the file fsync above is mandatory.
|
||||||
|
if (process.platform !== "win32") {
|
||||||
|
const dir = await fs.open(path.dirname(file), "r");
|
||||||
|
try { await dir.sync(); } finally { await dir.close(); }
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
throw fail("storage");
|
||||||
|
} finally {
|
||||||
|
await handle?.close().catch(() => {});
|
||||||
|
await fs.unlink(temp).catch(() => {});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function allowedAuthEndpoint(value) {
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
return url.origin === ISSUER && !url.username && !url.password && !url.search && !url.hash;
|
||||||
|
} catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function createChatGPT({ store, openExternal, fetch: request = globalThis.fetch, onChange = () => {}, timeoutMs = 180000 }) {
|
||||||
|
let state;
|
||||||
|
let init;
|
||||||
|
let queue = Promise.resolve();
|
||||||
|
let epoch = 0;
|
||||||
|
let revision = 0;
|
||||||
|
let authorityRevision = 0;
|
||||||
|
let catalogSequence = 0;
|
||||||
|
let storageDenied = false;
|
||||||
|
const stamp = () => ({ epoch, authorityRevision, revision });
|
||||||
|
let backgroundIntent;
|
||||||
|
let pending;
|
||||||
|
let catalog = [];
|
||||||
|
let catalogError = null;
|
||||||
|
let lastError = null;
|
||||||
|
let closed = false;
|
||||||
|
let paused = false;
|
||||||
|
let suspended = false;
|
||||||
|
let retryRegistration;
|
||||||
|
let transportAbort = new AbortController();
|
||||||
|
let backgroundAbort = new AbortController();
|
||||||
|
const serial = (fn) => {
|
||||||
|
const next = queue.then(fn, fn);
|
||||||
|
queue = next.catch(() => {});
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
const active = () => !storageDenied && !suspended && !closed && !paused ? state?.registrations.find((r) => r.id === state.activeId && r.tokens && !r.reconnectRequired) : undefined;
|
||||||
|
// These fields are validated/verified before storage. Email is never identity.
|
||||||
|
const sameAccount = (a, b) => a.issuer === b.issuer && a.subject === b.subject;
|
||||||
|
function accountGroups() {
|
||||||
|
const groups = [];
|
||||||
|
for (const record of state?.registrations || []) {
|
||||||
|
const group = groups.find((members) => sameAccount(members[0], record));
|
||||||
|
if (group) group.push(record); else groups.push([record]);
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
const backgroundAllowed = () => state?.allowBackgroundUse !== false && backgroundIntent?.enabled !== false;
|
||||||
|
const notify = (invalidate = "none") => { revision++; onChange({ channel: "lh:chatgpt", kind: "state", stamp: stamp(), status: snapshot(), invalidate }); };
|
||||||
|
function stop(backgroundOnly = false) {
|
||||||
|
if (backgroundOnly) {
|
||||||
|
backgroundAbort.abort();
|
||||||
|
backgroundAbort = new AbortController();
|
||||||
|
notify("background");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
suspended = true;
|
||||||
|
epoch++;
|
||||||
|
authorityRevision++;
|
||||||
|
catalogSequence++;
|
||||||
|
transportAbort.abort();
|
||||||
|
transportAbort = new AbortController();
|
||||||
|
catalog = [];
|
||||||
|
catalogError = null;
|
||||||
|
notify("all");
|
||||||
|
}
|
||||||
|
async function ready() {
|
||||||
|
if (closed) throw fail("disconnected");
|
||||||
|
if (!init) init = (async () => {
|
||||||
|
const loaded = await store.read();
|
||||||
|
state = loaded ? validateState(loaded) : null;
|
||||||
|
if (!state) {
|
||||||
|
const first = { version: 1, hostId: `urn:uuid:${crypto.randomUUID()}`, activeId: null, registrations: [] };
|
||||||
|
await store.write(first);
|
||||||
|
state = first;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
try { await init; } catch (err) { denyStorage(err); throw err; }
|
||||||
|
}
|
||||||
|
function denyStorage(err) {
|
||||||
|
if (!storageDenied) { storageDenied = true; lastError = publicError(err); stop(); }
|
||||||
|
}
|
||||||
|
async function save(next) {
|
||||||
|
try { validateState(next); await store.write(next); } catch (err) {
|
||||||
|
// A rotated token may already have invalidated its predecessor. Fail closed.
|
||||||
|
denyStorage(err);
|
||||||
|
lastError = publicError(err);
|
||||||
|
notify();
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
state = next;
|
||||||
|
}
|
||||||
|
function snapshot() {
|
||||||
|
return {
|
||||||
|
available: !!state && !closed && !paused,
|
||||||
|
pending: !!pending,
|
||||||
|
allowBackgroundUse: backgroundAllowed(),
|
||||||
|
activeId: state?.activeId ?? null,
|
||||||
|
accounts: accountGroups().map((members) => {
|
||||||
|
// Preserve the actual active registration ID at the private transport
|
||||||
|
// boundary; inactive groups prefer a usable registration, then a
|
||||||
|
// remembered one. No credential/client records are merged or deleted.
|
||||||
|
const r = members.find((r) => r.id === state.activeId) ?? members.find((r) => !r.reconnectRequired && planScopes(r.tokens)) ?? members[0];
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
email: r.email || "ChatGPT account",
|
||||||
|
registration: r.clientId,
|
||||||
|
connected: !storageDenied && !closed && !paused && !suspended && !r.reconnectRequired && planScopes(r.tokens),
|
||||||
|
planEnabled: !storageDenied && !r.reconnectRequired && planScopes(r.tokens),
|
||||||
|
// Recovery is authorization-only, not a response to storage failure
|
||||||
|
// or temporary transport suspension. Never infer it from error copy.
|
||||||
|
canSignInAgain: !storageDenied && !closed && !paused && (r.reconnectRequired === true || !planScopes(r.tokens)),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
error: lastError,
|
||||||
|
catalogError,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async function status() {
|
||||||
|
try { await ready(); } catch (err) { return { ...snapshot(), available: false, error: publicError(err) }; }
|
||||||
|
return snapshot();
|
||||||
|
}
|
||||||
|
async function json(url, options = {}) {
|
||||||
|
try {
|
||||||
|
const response = await request(url, { ...options, redirect: "error", signal: options.signal ?? AbortSignal.timeout(15000) });
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.json().catch(() => ({}));
|
||||||
|
const oauthCode = typeof body.error === "string" ? body.error : body.error?.code;
|
||||||
|
throw Object.assign(fail(oauthCode === "invalid_grant" || response.status === 401 || response.status === 403 ? "expired" : "network"), { oauthCode });
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
|
} catch (err) { throw err.code ? err : fail("network"); }
|
||||||
|
}
|
||||||
|
async function identity(idToken, clientId, nonce, expectedSubject) {
|
||||||
|
try {
|
||||||
|
const { jwtVerify, createLocalJWKSet } = await import("jose");
|
||||||
|
// Fixed, documented JWKS origin; never follow a discovery-supplied URL.
|
||||||
|
const keys = await json(JWKS);
|
||||||
|
const { payload } = await jwtVerify(idToken, createLocalJWKSet(keys), {
|
||||||
|
issuer: ISSUER, audience: clientId, algorithms: ["RS256"],
|
||||||
|
requiredClaims: ["sub", "exp", "iat"], clockTolerance: 5,
|
||||||
|
});
|
||||||
|
if (typeof payload.sub !== "string" || !payload.sub || (nonce !== undefined && payload.nonce !== nonce) || (expectedSubject && payload.sub !== expectedSubject)) throw fail("identity");
|
||||||
|
if ((payload.azp !== undefined && payload.azp !== clientId) || (Array.isArray(payload.aud) && payload.aud.length > 1 && payload.azp !== clientId)) throw fail("identity");
|
||||||
|
return { subject: payload.sub, email: typeof payload.email === "string" ? payload.email : undefined };
|
||||||
|
} catch { throw fail("identity"); }
|
||||||
|
}
|
||||||
|
function tokenSet(body, previous) {
|
||||||
|
const scopes = typeof body.scope === "string" ? body.scope.split(/\s+/) : previous?.scopes;
|
||||||
|
if (!scopes || SCOPES.some((scope) => !scopes.includes(scope))) {
|
||||||
|
// Lack of entitlement is not lack of revocable material.
|
||||||
|
return { idToken: body.id_token ?? previous?.idToken, scopes: scopes ?? [], expiresAt: 0,
|
||||||
|
...(nonempty(body.refresh_token) ? { refreshToken: body.refresh_token } : previous?.refreshToken ? { refreshToken: previous.refreshToken } : {}) };
|
||||||
|
}
|
||||||
|
if (typeof body.access_token !== "string" || !body.access_token || body.access_token.startsWith("sk-") || body.token_type?.toLowerCase() !== "bearer" || !Number.isFinite(body.expires_in) || body.expires_in <= 0) throw fail("identity");
|
||||||
|
const refreshToken = body.refresh_token ?? previous?.refreshToken;
|
||||||
|
if (!nonempty(refreshToken)) throw fail("identity");
|
||||||
|
return { accessToken: body.access_token, refreshToken, idToken: body.id_token ?? previous?.idToken, scopes, expiresAt: Date.now() + body.expires_in * 1000 };
|
||||||
|
}
|
||||||
|
async function grant(fields, signal) {
|
||||||
|
return json(TOKEN, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" }, body: new URLSearchParams({ ...fields, resource: RESOURCE }), signal });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(accountId) {
|
||||||
|
await ready();
|
||||||
|
if (pending) throw fail("pending");
|
||||||
|
if (paused) throw fail("disconnected");
|
||||||
|
const selected = accountId ? state.registrations.find((r) => r.id === accountId) : retryRegistration;
|
||||||
|
if (accountId && !selected) throw fail("identity");
|
||||||
|
const attemptEpoch = epoch;
|
||||||
|
const verifier = random();
|
||||||
|
const nonce = random();
|
||||||
|
const expectedState = random();
|
||||||
|
const abort = new AbortController();
|
||||||
|
let finish;
|
||||||
|
const completion = new Promise((resolve) => { finish = resolve; });
|
||||||
|
const listener = http.createServer();
|
||||||
|
let timer;
|
||||||
|
let consumed = false;
|
||||||
|
const cleanup = () => { clearTimeout(timer); listener.close(); listener.closeAllConnections(); };
|
||||||
|
const cancel = (code = "cancelled") => {
|
||||||
|
abort.abort();
|
||||||
|
cleanup();
|
||||||
|
if (pending?.abort === abort) pending = null;
|
||||||
|
lastError = publicError(fail(code));
|
||||||
|
finish();
|
||||||
|
notify();
|
||||||
|
};
|
||||||
|
pending = { abort, cancel };
|
||||||
|
lastError = null;
|
||||||
|
notify();
|
||||||
|
try {
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
listener.once("error", reject);
|
||||||
|
listener.listen(0, "127.0.0.1", resolve);
|
||||||
|
});
|
||||||
|
const redirectUri = `http://127.0.0.1:${listener.address().port}/auth/callback`;
|
||||||
|
listener.on("request", async (req, res) => {
|
||||||
|
let callback;
|
||||||
|
try { callback = new URL(req.url || "/", redirectUri); }
|
||||||
|
catch { res.writeHead(400).end(); return; }
|
||||||
|
res.setHeader("Cache-Control", "no-store");
|
||||||
|
res.setHeader("Content-Security-Policy", "default-src 'none'");
|
||||||
|
res.setHeader("Referrer-Policy", "no-referrer");
|
||||||
|
if (req.method !== "GET" || callback.origin !== new URL(redirectUri).origin || callback.pathname !== "/auth/callback" || req.headers.host !== new URL(redirectUri).host || consumed) { res.writeHead(404).end(); return; }
|
||||||
|
const params = callback.searchParams;
|
||||||
|
// Reject duplicate parameters and unsolicited callbacks without consuming a valid pending attempt.
|
||||||
|
if (["state", "code", "client_id", "error"].some((key) => params.getAll(key).length > 1) || params.get("state") !== expectedState) { res.writeHead(400).end("Sign-in could not be verified."); return; }
|
||||||
|
consumed = true;
|
||||||
|
res.writeHead(200, { "content-type": "text/plain; charset=utf-8" }).end("Return to LearnHarness to view the sign-in result. You may close this tab.");
|
||||||
|
cleanup();
|
||||||
|
try {
|
||||||
|
if (params.has("error")) throw fail(params.get("error") === "access_denied" ? "cancelled" : "identity");
|
||||||
|
const clientId = params.get("client_id") || selected?.clientId;
|
||||||
|
if (!clientId || clientId === "dynamic_agent_client" || clientId.length > 512 || /[\s\x00-\x1f]/.test(clientId) || (selected && clientId !== selected.clientId) || !params.get("code")) throw fail("identity");
|
||||||
|
let body;
|
||||||
|
try { body = await grant({ grant_type: "authorization_code", client_id: clientId, code: params.get("code"), code_verifier: verifier, redirect_uri: redirectUri }, AbortSignal.any([abort.signal, AbortSignal.timeout(15000)])); }
|
||||||
|
catch (err) {
|
||||||
|
if (err.oauthCode === "invalid_grant" && !selected?.subject) retryRegistration = { clientId };
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if (typeof body.id_token !== "string") throw fail("identity");
|
||||||
|
const verified = await identity(body.id_token, clientId, nonce, selected?.subject);
|
||||||
|
const tokens = tokenSet(body);
|
||||||
|
await serial(async () => {
|
||||||
|
if (closed || abort.signal.aborted || epoch !== attemptEpoch) throw fail("cancelled");
|
||||||
|
const existing = state.registrations.find((r) => r.clientId === clientId);
|
||||||
|
if (existing && existing.subject !== verified.subject) throw fail("identity");
|
||||||
|
const record = { id: existing?.id ?? crypto.randomUUID(), clientId, issuer: ISSUER, ...verified, tokens };
|
||||||
|
const next = structuredClone(state);
|
||||||
|
next.registrations = next.registrations.filter((r) => r.id !== record.id).concat(record);
|
||||||
|
next.activeId = record.id;
|
||||||
|
const previous = structuredClone(state);
|
||||||
|
await save(next); // Active identity changes only AFTER validation and durable persistence.
|
||||||
|
if (closed || abort.signal.aborted || epoch !== attemptEpoch) {
|
||||||
|
await save(previous);
|
||||||
|
throw fail("cancelled");
|
||||||
|
}
|
||||||
|
stop();
|
||||||
|
storageDenied = false;
|
||||||
|
suspended = false;
|
||||||
|
retryRegistration = undefined;
|
||||||
|
});
|
||||||
|
lastError = planScopes(tokens) ? null : publicError(fail("scope"));
|
||||||
|
} catch (err) { if (!abort.signal.aborted) lastError = publicError(err); }
|
||||||
|
finally { if (pending?.abort === abort) pending = null; finish(); notify(); }
|
||||||
|
});
|
||||||
|
timer = setTimeout(() => cancel("timeout"), timeoutMs);
|
||||||
|
const url = new URL(AUTHORIZE);
|
||||||
|
url.search = new URLSearchParams({
|
||||||
|
client_id: selected?.clientId ?? "dynamic_agent_client",
|
||||||
|
...(selected ? {} : { agent_name_hint: "LearnHarness" }),
|
||||||
|
ext_agent_host_id: state.hostId,
|
||||||
|
...(selected?.tokens?.idToken ? { id_token_hint: selected.tokens.idToken } : {}),
|
||||||
|
...(selected?.email ? { login_hint: selected.email } : {}),
|
||||||
|
response_type: "code", redirect_uri: redirectUri, scope: SCOPES.join(" "), resource: RESOURCE,
|
||||||
|
state: expectedState, nonce, code_challenge_method: "S256",
|
||||||
|
code_challenge: crypto.createHash("sha256").update(verifier).digest("base64url"),
|
||||||
|
}).toString();
|
||||||
|
await openExternal(url.toString()); // Trusted main only. Never return this URL to the renderer.
|
||||||
|
} catch (err) { cancel(err.code || "network"); }
|
||||||
|
void completion;
|
||||||
|
return snapshot();
|
||||||
|
}
|
||||||
|
function cancelLogin() { pending?.cancel(); return snapshot(); }
|
||||||
|
|
||||||
|
function authorized(background, expected, capturedEpoch) {
|
||||||
|
const account = active();
|
||||||
|
if (!account || (capturedEpoch !== undefined && capturedEpoch !== epoch)) throw fail("disconnected");
|
||||||
|
if (!planScopes(account.tokens)) throw fail("scope");
|
||||||
|
if (expected && (expected.expectedAccountId !== account.id || expected.expectedAuthorityRevision !== authorityRevision || !catalog.some((m) => m.id === expected.selectedModelId))) throw fail("catalog");
|
||||||
|
if (background && !backgroundAllowed()) throw fail("background");
|
||||||
|
return account;
|
||||||
|
}
|
||||||
|
async function credential(background, expected) {
|
||||||
|
await ready();
|
||||||
|
return serial(async () => {
|
||||||
|
const requestEpoch = epoch;
|
||||||
|
const account = authorized(background, expected);
|
||||||
|
if (account.tokens.expiresAt <= Date.now() + 60000) {
|
||||||
|
const refreshEpoch = epoch;
|
||||||
|
const signal = AbortSignal.any([transportAbort.signal, ...(background ? [backgroundAbort.signal] : []), AbortSignal.timeout(15000)]);
|
||||||
|
let body;
|
||||||
|
try { body = await grant({ grant_type: "refresh_token", client_id: account.clientId, refresh_token: account.tokens.refreshToken }, signal); }
|
||||||
|
catch (err) {
|
||||||
|
if (err.oauthCode === "invalid_grant") {
|
||||||
|
const next = structuredClone(state);
|
||||||
|
delete next.registrations.find((r) => r.id === account.id).tokens;
|
||||||
|
next.activeId = null;
|
||||||
|
await save(next);
|
||||||
|
stop();
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
let tokens;
|
||||||
|
try {
|
||||||
|
if (body.id_token !== undefined) await identity(body.id_token, account.clientId, undefined, account.subject);
|
||||||
|
tokens = tokenSet(body, account.tokens);
|
||||||
|
} catch (err) {
|
||||||
|
// The fixed token endpoint may have rotated already. Preserve only
|
||||||
|
// verified identity and a single likely-latest revocation handle.
|
||||||
|
stop();
|
||||||
|
const restricted = structuredClone(state);
|
||||||
|
const record = restricted.registrations.find((r) => r.id === account.id);
|
||||||
|
record.reconnectRequired = true;
|
||||||
|
if (nonempty(body.refresh_token)) record.tokens.refreshToken = body.refresh_token;
|
||||||
|
await save(restricted);
|
||||||
|
lastError = publicError(fail("identity")); notify();
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
// A successful remote rotation must be durably retained even when
|
||||||
|
// logout, account selection, consent revocation, or pause denies use.
|
||||||
|
// Those serialized operations will revoke/clear the LATEST token.
|
||||||
|
const next = structuredClone(state);
|
||||||
|
next.registrations.find((r) => r.id === account.id).tokens = tokens;
|
||||||
|
await save(next);
|
||||||
|
if (closed || refreshEpoch !== epoch || signal.aborted) throw fail("disconnected");
|
||||||
|
}
|
||||||
|
const current = authorized(background, expected, requestEpoch);
|
||||||
|
return { accessToken: current.tokens.accessToken, accountId: current.id, ...stamp() };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function lease(background, expected) {
|
||||||
|
if (typeof background !== "boolean" || !expected || !nonempty(expected.expectedAccountId) || !Number.isSafeInteger(expected.expectedAuthorityRevision) || !nonempty(expected.selectedModelId)) throw fail("catalog");
|
||||||
|
return credential(background, expected);
|
||||||
|
}
|
||||||
|
const modelValue = () => ({ ...stamp(), status: snapshot(), accountId: active()?.id ?? null, models: catalog });
|
||||||
|
async function models() {
|
||||||
|
const sequence = ++catalogSequence;
|
||||||
|
try {
|
||||||
|
await ready();
|
||||||
|
if (!active()) return modelValue();
|
||||||
|
// Discovery is identity/scope authorization, not an invented model lease.
|
||||||
|
const token = await credential(false);
|
||||||
|
const body = await json(`${RESOURCE}/models`, { headers: { Authorization: `Bearer ${token.accessToken}` }, signal: AbortSignal.any([transportAbort.signal, AbortSignal.timeout(15000)]) });
|
||||||
|
authorized(false, undefined, token.epoch);
|
||||||
|
if (sequence !== catalogSequence || active()?.id !== token.accountId || !Array.isArray(body.models)) throw fail("catalog");
|
||||||
|
const visible = body.models.filter((m) => m && m.visibility === "list" && nonempty(m.slug) && typeof m.display_name === "string").map((m) => ({ id: m.slug, name: m.display_name }));
|
||||||
|
const changed = JSON.stringify(visible) !== JSON.stringify(catalog);
|
||||||
|
catalog = visible;
|
||||||
|
if (changed) authorityRevision++;
|
||||||
|
const hadError = catalogError !== null; catalogError = null;
|
||||||
|
if (changed || hadError) notify();
|
||||||
|
return modelValue();
|
||||||
|
} catch {
|
||||||
|
if (sequence === catalogSequence) {
|
||||||
|
if (catalog.length) { catalog = []; authorityRevision++; }
|
||||||
|
catalogError = publicError(fail("catalog")); notify();
|
||||||
|
}
|
||||||
|
return modelValue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function select(id) {
|
||||||
|
pending?.cancel();
|
||||||
|
stop();
|
||||||
|
const selectionIntent = epoch;
|
||||||
|
await ready();
|
||||||
|
return serial(async () => {
|
||||||
|
const account = state.registrations.find((r) => r.id === id);
|
||||||
|
if (!account?.tokens) throw fail("disconnected");
|
||||||
|
await save({ ...state, activeId: id });
|
||||||
|
if (epoch !== selectionIntent || closed || paused || storageDenied) throw fail("disconnected");
|
||||||
|
suspended = false;
|
||||||
|
lastError = null;
|
||||||
|
notify();
|
||||||
|
return snapshot();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function consent(enabled) {
|
||||||
|
if (typeof enabled !== "boolean") throw fail("identity");
|
||||||
|
// One app-owned intent, independent of account/registration lifetime.
|
||||||
|
const intent = { enabled: false };
|
||||||
|
backgroundIntent = intent; // ON cannot grant until its durable save.
|
||||||
|
if (!enabled) stop(true); // Revoke all ChatGPT background streams only.
|
||||||
|
await ready();
|
||||||
|
return serial(async () => {
|
||||||
|
await save({ ...structuredClone(state), allowBackgroundUse: enabled });
|
||||||
|
if (backgroundIntent === intent && enabled) backgroundIntent = undefined;
|
||||||
|
notify();
|
||||||
|
return snapshot();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function revoke(account) {
|
||||||
|
let revoked = false;
|
||||||
|
try {
|
||||||
|
if (account.tokens?.refreshToken) {
|
||||||
|
const config = await json(`${ISSUER}/.well-known/openid-configuration`);
|
||||||
|
if (config.issuer !== ISSUER || !allowedAuthEndpoint(config.revocation_endpoint)) throw fail("identity");
|
||||||
|
for (let attempt = 0; attempt < 2; attempt++) {
|
||||||
|
if (attempt) await new Promise((resolve) => setTimeout(resolve, 200));
|
||||||
|
try {
|
||||||
|
const response = await request(config.revocation_endpoint, { method: "POST", redirect: "error", signal: AbortSignal.timeout(5000), headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ token: account.tokens.refreshToken, token_type_hint: "refresh_token", client_id: account.clientId }) });
|
||||||
|
revoked = response.status === 200;
|
||||||
|
if (revoked || response.status < 500) break;
|
||||||
|
} catch { /* retry only while the refresh token is still in memory */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* local action is mandatory even when revocation is unavailable */ }
|
||||||
|
return revoked;
|
||||||
|
}
|
||||||
|
async function disconnect(id) {
|
||||||
|
// Explicit local forgetting: a visible account represents every retained
|
||||||
|
// registration with this exact verified issuer/subject, never its email.
|
||||||
|
pending?.cancel();
|
||||||
|
const initial = state?.registrations.find((r) => r.id === id);
|
||||||
|
let stopped = !!initial && state.registrations.some((r) => r.id === state.activeId && sameAccount(r, initial));
|
||||||
|
if (stopped) stop();
|
||||||
|
await ready();
|
||||||
|
return serial(async () => {
|
||||||
|
const target = state.registrations.find((r) => r.id === id);
|
||||||
|
if (!target) throw fail("identity");
|
||||||
|
const members = state.registrations.filter((r) => sameAccount(r, target));
|
||||||
|
const activeGroup = members.some((r) => r.id === state.activeId);
|
||||||
|
if (activeGroup && !stopped) { stop(); stopped = true; }
|
||||||
|
const disconnectEpoch = epoch;
|
||||||
|
let revoked = true;
|
||||||
|
for (const member of members) { const confirmed = await revoke(member); revoked = confirmed && revoked; }
|
||||||
|
const next = structuredClone(state);
|
||||||
|
next.registrations = next.registrations.filter((r) => !sameAccount(r, target));
|
||||||
|
if (activeGroup) next.activeId = null; // Never silently select another account.
|
||||||
|
await save(next);
|
||||||
|
if (members.some((r) => r.clientId === retryRegistration?.clientId)) retryRegistration = undefined;
|
||||||
|
if (stopped && !closed && epoch === disconnectEpoch) suspended = false;
|
||||||
|
lastError = revoked ? null : REVOCATION_WARNING;
|
||||||
|
notify();
|
||||||
|
return snapshot();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function logout(id) {
|
||||||
|
pending?.cancel();
|
||||||
|
stop();
|
||||||
|
await ready();
|
||||||
|
return serial(async () => {
|
||||||
|
const account = state.registrations.find((r) => r.id === id);
|
||||||
|
if (!account) throw fail("identity");
|
||||||
|
const revoked = await revoke(account);
|
||||||
|
const next = structuredClone(state);
|
||||||
|
delete next.registrations.find((r) => r.id === id).tokens;
|
||||||
|
if (next.activeId === id) next.activeId = null;
|
||||||
|
const logoutEpoch = epoch;
|
||||||
|
await save(next);
|
||||||
|
if (!closed && epoch === logoutEpoch) suspended = false;
|
||||||
|
lastError = revoked ? null : REVOCATION_WARNING;
|
||||||
|
notify();
|
||||||
|
return snapshot();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function close() { closed = true; pending?.cancel(); stop(); }
|
||||||
|
function pause() { paused = true; pending?.cancel(); stop(); notify(); }
|
||||||
|
function resume() { paused = false; if (!storageDenied) suspended = false; notify(); }
|
||||||
|
return { status, login, cancelLogin, lease, models, select, consent, disconnect, logout, close, pause, resume, stamp };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createChatGPT, fileStore, publicError, allowedAuthEndpoint, SCOPES, validateState };
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const HEADER = "x-learnharness-desktop";
|
||||||
|
|
||||||
|
/** Called for EVERY outgoing request, so redirects cannot carry the secret
|
||||||
|
* to another origin. Never expose this function or its secret to preload. */
|
||||||
|
function desktopAPIHeaders(details, origin, secret, windows) {
|
||||||
|
const headers = { ...details.requestHeaders };
|
||||||
|
for (const name of Object.keys(headers)) if (name.toLowerCase() === HEADER) delete headers[name];
|
||||||
|
if (!secret) return headers;
|
||||||
|
try {
|
||||||
|
const target = new URL(details.url);
|
||||||
|
if (target.origin !== origin || target.username || target.password || !(target.pathname === "/api" || target.pathname.startsWith("/api/"))) return headers;
|
||||||
|
const win = windows.find((candidate) => !candidate.isDestroyed() && candidate.webContents.id === details.webContentsId);
|
||||||
|
if (!win || win.webContents.isDestroyed()) return headers;
|
||||||
|
const contents = win.webContents;
|
||||||
|
if (details.webContents && details.webContents !== contents) return headers;
|
||||||
|
if (!details.frame || details.frame !== contents.mainFrame) return headers;
|
||||||
|
if (new URL(contents.mainFrame.url).origin !== origin) return headers;
|
||||||
|
if (details.initiatorOrigin !== undefined && details.initiatorOrigin !== origin) return headers;
|
||||||
|
headers[HEADER] = secret;
|
||||||
|
} catch { /* Missing/destroyed/navigated frames fail closed. */ }
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
module.exports = { desktopAPIHeaders, HEADER };
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/** Native-shell catalog. Kept with the shell so packaged builds do not load server sources. */
|
||||||
|
const ru = {
|
||||||
|
"Check for Updates…": "Проверить обновления…", "Edit": "Правка", "View": "Вид", "Window": "Окно",
|
||||||
|
"Actual Size": "Исходный масштаб", "Zoom In": "Увеличить масштаб", "Zoom Out": "Уменьшить масштаб",
|
||||||
|
"About LearnHarness": "О LearnHarness", "Hide LearnHarness": "Скрыть LearnHarness", "Hide Others": "Скрыть остальные", "Quit LearnHarness": "Выйти из LearnHarness",
|
||||||
|
"Undo": "Отменить", "Redo": "Повторить", "Cut": "Вырезать", "Copy": "Копировать", "Paste": "Вставить", "Select All": "Выбрать всё",
|
||||||
|
"Reload": "Перезагрузить", "Developer Tools": "Инструменты разработчика", "Toggle Full Screen": "Полноэкранный режим", "Minimize": "Свернуть", "Close": "Закрыть",
|
||||||
|
"You're up to date.": "Установлена последняя версия.", "Couldn't check for updates.": "Не удалось проверить обновления.",
|
||||||
|
"LearnHarness {version} is the latest version.": "LearnHarness {version} — последняя версия.", "OK": "ОК",
|
||||||
|
};
|
||||||
|
function resolveLanguage(preference, languages = []) { return preference === "ru" || preference !== "en" && /^ru(?:-|_|$)/i.test(languages[0] || "") ? "ru" : "en"; }
|
||||||
|
function translate(language, text, params = {}) { return (language === "ru" ? ru[text] || text : text).replace(/\{(\w+)\}/g, (match, key) => Object.hasOwn(params, key) ? String(params[key]) : match); }
|
||||||
|
module.exports = { resolveLanguage, translate };
|
||||||
+111
-19
@@ -25,6 +25,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const { app, BrowserWindow, Menu, ipcMain, session, shell, dialog } = require("electron");
|
const { app, BrowserWindow, Menu, ipcMain, session, shell, dialog } = require("electron");
|
||||||
|
const { createChatGPT, fileStore } = require("./chatgpt.cjs");
|
||||||
|
const { attachChatGPTChild, trustedSender, chatGPTControl } = require("./chatgpt-ipc.cjs");
|
||||||
|
const { createSubscriptions, loadSubscriptionSDK } = require("./subscriptions.cjs");
|
||||||
|
const { attachSubscriptionChild, subscriptionControl } = require("./subscription-ipc.cjs");
|
||||||
|
const { desktopAPIHeaders } = require("./desktop-api.cjs");
|
||||||
|
const { randomBytes } = require("node:crypto");
|
||||||
const { spawn } = require("node:child_process");
|
const { spawn } = require("node:child_process");
|
||||||
const fs = require("node:fs");
|
const fs = require("node:fs");
|
||||||
const http = require("node:http");
|
const http = require("node:http");
|
||||||
@@ -128,6 +134,13 @@ if (process.argv.includes("--browser-service")) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let serverProcess = null;
|
let serverProcess = null;
|
||||||
|
let chatgpt = null;
|
||||||
|
let chatgptChild = null;
|
||||||
|
let subscriptions = null;
|
||||||
|
let subscriptionChild = null;
|
||||||
|
// Per-launch request capability, never OAuth tokens and never renderer state.
|
||||||
|
let desktopAPISecret = null;
|
||||||
|
const trustedAppWindows = new Set();
|
||||||
|
|
||||||
// A GUI shell must never die to a stray exception (EPIPE dialogs, a race
|
// A GUI shell must never die to a stray exception (EPIPE dialogs, a race
|
||||||
// in an event handler): the window + child server are the product, and
|
// in an event handler): the window + child server are the product, and
|
||||||
@@ -187,7 +200,11 @@ function startServer(port) {
|
|||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const child = spawn(
|
const child = spawn(
|
||||||
process.execPath,
|
process.execPath,
|
||||||
[serverEntry()],
|
// Only the macOS backend needs this: Next's native title setter
|
||||||
|
// otherwise checks this Node-mode process in as a foreground app.
|
||||||
|
process.platform === "darwin"
|
||||||
|
? ["--require", path.join(process.resourcesPath, "server-title-guard.cjs"), serverEntry()]
|
||||||
|
: [serverEntry()],
|
||||||
{
|
{
|
||||||
cwd: path.dirname(serverEntry()),
|
cwd: path.dirname(serverEntry()),
|
||||||
env: {
|
env: {
|
||||||
@@ -197,21 +214,32 @@ function startServer(port) {
|
|||||||
PORT: String(port),
|
PORT: String(port),
|
||||||
HOSTNAME: "127.0.0.1", // the server defaults to 0.0.0.0 — never
|
HOSTNAME: "127.0.0.1", // the server defaults to 0.0.0.0 — never
|
||||||
LH_DATA_DIR: DATA_DIR(),
|
LH_DATA_DIR: DATA_DIR(),
|
||||||
|
LH_SYSTEM_LANGUAGE: app.getPreferredSystemLanguages()[0] || "en",
|
||||||
// The auth/keys store — the child reads AND writes this file itself
|
// The auth/keys store — the child reads AND writes this file itself
|
||||||
// (no env injection anymore: a spawn-time copy would shadow every
|
// (no env injection anymore: a spawn-time copy would shadow every
|
||||||
// settings-page key change until restart; env wins over file).
|
// settings-page key change until restart; env wins over file).
|
||||||
LH_AUTH_FILE: AUTH_FILE(),
|
LH_AUTH_FILE: AUTH_FILE(),
|
||||||
|
LH_DESKTOP_SERVER: "1",
|
||||||
|
LH_DESKTOP_API_SECRET: desktopAPISecret,
|
||||||
},
|
},
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe", "ipc"],
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
serverProcess = child;
|
serverProcess = child;
|
||||||
|
chatgptChild = attachChatGPTChild(child, chatgpt);
|
||||||
|
subscriptionChild = attachSubscriptionChild(child, subscriptions);
|
||||||
const fwd = (buf) => buf.toString().trimEnd().split("\n").forEach((l) => l && log(`server: ${l}`));
|
const fwd = (buf) => buf.toString().trimEnd().split("\n").forEach((l) => l && log(`server: ${l}`));
|
||||||
child.stdout.on("data", fwd);
|
child.stdout.on("data", fwd);
|
||||||
child.stderr.on("data", fwd);
|
child.stderr.on("data", fwd);
|
||||||
child.on("exit", (code, signal) => {
|
child.on("exit", (code, signal) => {
|
||||||
log(`server exited (code=${code} signal=${signal})`);
|
log(`server exited (code=${code} signal=${signal})`);
|
||||||
serverProcess = null;
|
serverProcess = null;
|
||||||
|
chatgptChild?.dispose();
|
||||||
|
chatgptChild = null;
|
||||||
|
subscriptionChild?.dispose();
|
||||||
|
subscriptionChild = null;
|
||||||
|
chatgpt?.close();
|
||||||
|
subscriptions?.close();
|
||||||
});
|
});
|
||||||
resolve(child);
|
resolve(child);
|
||||||
});
|
});
|
||||||
@@ -235,6 +263,8 @@ function waitUntilUp(url, tries = 150) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function killServer() {
|
function killServer() {
|
||||||
|
chatgpt?.close();
|
||||||
|
subscriptions?.close();
|
||||||
if (!serverProcess) return;
|
if (!serverProcess) return;
|
||||||
const child = serverProcess;
|
const child = serverProcess;
|
||||||
serverProcess = null;
|
serverProcess = null;
|
||||||
@@ -306,6 +336,8 @@ function createWindow(url) {
|
|||||||
minHeight: 600,
|
minHeight: 600,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
trustedAppWindows.add(win);
|
||||||
|
win.once("closed", () => trustedAppWindows.delete(win));
|
||||||
const allowedOrigin = () => new URL(url).origin;
|
const allowedOrigin = () => new URL(url).origin;
|
||||||
// Deny-by-default; the ONLY grants are media (mic/STT groundwork) and
|
// Deny-by-default; the ONLY grants are media (mic/STT groundwork) and
|
||||||
// clipboard-sanitized-write — the copy affordances in the transcript
|
// clipboard-sanitized-write — the copy affordances in the transcript
|
||||||
@@ -425,11 +457,11 @@ function checkForUpdates({ manual = false } = {}) {
|
|||||||
// update-downloaded. Manual checks additionally answer directly.
|
// update-downloaded. Manual checks additionally answer directly.
|
||||||
if (!manual) return;
|
if (!manual) return;
|
||||||
const version = result?.updateInfo?.version;
|
const version = result?.updateInfo?.version;
|
||||||
if (!version) dialog.showMessageBox({ type: "info", message: "You're up to date.", detail: `LearnHarness ${app.getVersion()} is the latest version.` });
|
if (!version) dialog.showMessageBox({ type: "info", message: nt("You're up to date."), detail: nt("LearnHarness {version} is the latest version.", { version: app.getVersion() }), buttons: [nt("OK")] });
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
log(`update check failed: ${err?.message ?? err}`);
|
log(`update check failed: ${err?.message ?? err}`);
|
||||||
if (manual) dialog.showMessageBox({ type: "warning", message: "Couldn't check for updates.", detail: String(err?.message ?? err) });
|
if (manual) dialog.showMessageBox({ type: "warning", message: nt("Couldn't check for updates."), buttons: [nt("OK")], detail: String(err?.message ?? err) });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -493,59 +525,71 @@ function applyZoom(win, factor) {
|
|||||||
|
|
||||||
/** macOS needs an application menu with the Edit roles — without one,
|
/** macOS needs an application menu with the Edit roles — without one,
|
||||||
* Cmd+C/V/A do nothing in the renderer. Everything else stays minimal. */
|
* Cmd+C/V/A do nothing in the renderer. Everything else stays minimal. */
|
||||||
|
const { resolveLanguage: resolveShellLanguage, translate: translateShell } = require("./language.cjs");
|
||||||
|
let shellLanguage;
|
||||||
|
function nativeLanguage() {
|
||||||
|
if (shellLanguage) return shellLanguage;
|
||||||
|
let preference;
|
||||||
|
try { preference = JSON.parse(fs.readFileSync(path.join(DATA_DIR(), "settings.json"), "utf8")).general?.language; } catch {}
|
||||||
|
return resolveShellLanguage(preference, app.getPreferredSystemLanguages());
|
||||||
|
}
|
||||||
|
function nt(text, params) { return translateShell(nativeLanguage(), text, params); }
|
||||||
|
|
||||||
function installMenu() {
|
function installMenu() {
|
||||||
const template = [
|
const template = [
|
||||||
{
|
{
|
||||||
label: app.name,
|
label: app.name,
|
||||||
submenu: [
|
submenu: [
|
||||||
{ role: "about" },
|
{ role: "about", label: nt("About LearnHarness") },
|
||||||
// Manual check (Wave C): answers via native dialog — no renderer
|
// Manual check (Wave C): answers via native dialog — no renderer
|
||||||
// round-trip. The 24h automatic checks stay silent.
|
// round-trip. The 24h automatic checks stay silent.
|
||||||
{ label: "Check for Updates…", click: () => checkForUpdates({ manual: true }) },
|
{ label: nt("Check for Updates…"), click: () => checkForUpdates({ manual: true }) },
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{ role: "hide" },
|
{ role: "hide", label: nt("Hide LearnHarness") },
|
||||||
{ role: "hideOthers" },
|
{ role: "hideOthers", label: nt("Hide Others") },
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{ role: "quit" },
|
{ role: "quit", label: nt("Quit LearnHarness") },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ label: "Edit", submenu: [{ role: "undo" }, { role: "redo" }, { type: "separator" }, { role: "cut" }, { role: "copy" }, { role: "paste" }, { role: "selectAll" }] },
|
{ label: nt("Edit"), submenu: [{ role: "undo", label: nt("Undo") }, { role: "redo", label: nt("Redo") }, { type: "separator" }, { role: "cut", label: nt("Cut") }, { role: "copy", label: nt("Copy") }, { role: "paste", label: nt("Paste") }, { role: "selectAll", label: nt("Select All") }] },
|
||||||
{
|
{
|
||||||
label: "View",
|
label: nt("View"),
|
||||||
submenu: [
|
submenu: [
|
||||||
{ role: "reload" },
|
{ role: "reload", label: nt("Reload") },
|
||||||
{ role: "toggleDevTools" },
|
{ role: "toggleDevTools", label: nt("Developer Tools") },
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
// OWNED zoom (see applyZoom): the roles change zoomLevel
|
// OWNED zoom (see applyZoom): the roles change zoomLevel
|
||||||
// silently; these broadcast the factor and re-seat the
|
// silently; these broadcast the factor and re-seat the
|
||||||
// traffic lights. The renderer's --lh-zoom depends on
|
// traffic lights. The renderer's --lh-zoom depends on
|
||||||
// hearing every change.
|
// hearing every change.
|
||||||
{
|
{
|
||||||
label: "Actual Size",
|
label: nt("Actual Size"),
|
||||||
accelerator: "CommandOrControl+0",
|
accelerator: "CommandOrControl+0",
|
||||||
click: () => applyZoom(BrowserWindow.getAllWindows()[0], 1),
|
click: () => applyZoom(BrowserWindow.getAllWindows()[0], 1),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Zoom In",
|
label: nt("Zoom In"),
|
||||||
accelerator: "CommandOrControl+Plus",
|
accelerator: "CommandOrControl+Plus",
|
||||||
click: () => zoomStep(BrowserWindow.getAllWindows()[0], +1),
|
click: () => zoomStep(BrowserWindow.getAllWindows()[0], +1),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Zoom Out",
|
label: nt("Zoom Out"),
|
||||||
accelerator: "CommandOrControl+-",
|
accelerator: "CommandOrControl+-",
|
||||||
click: () => zoomStep(BrowserWindow.getAllWindows()[0], -1),
|
click: () => zoomStep(BrowserWindow.getAllWindows()[0], -1),
|
||||||
},
|
},
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{ role: "togglefullscreen" },
|
{ role: "togglefullscreen", label: nt("Toggle Full Screen") },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ role: "windowMenu" },
|
{ label: nt("Window"), submenu: [{ role: "minimize", label: nt("Minimize") }, { role: "close", label: nt("Close") }] },
|
||||||
];
|
];
|
||||||
Menu.setApplicationMenu(Menu.buildFromTemplate(template));
|
Menu.setApplicationMenu(Menu.buildFromTemplate(template));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
app.on("window-all-closed", () => {
|
app.on("window-all-closed", () => {
|
||||||
|
chatgpt?.pause();
|
||||||
|
subscriptions?.pause();
|
||||||
// macOS convention: the app stays resident, reactivated from the Dock.
|
// macOS convention: the app stays resident, reactivated from the Dock.
|
||||||
if (process.platform !== "darwin") app.quit();
|
if (process.platform !== "darwin") app.quit();
|
||||||
});
|
});
|
||||||
@@ -563,6 +607,30 @@ async function main() {
|
|||||||
const devUrl = process.env.LH_DEV_URL;
|
const devUrl = process.env.LH_DEV_URL;
|
||||||
const url = devUrl ?? `http://127.0.0.1:${await freePort()}`;
|
const url = devUrl ?? `http://127.0.0.1:${await freePort()}`;
|
||||||
if (!devUrl) {
|
if (!devUrl) {
|
||||||
|
desktopAPISecret = randomBytes(32).toString("hex");
|
||||||
|
const ownedOrigin = new URL(url).origin;
|
||||||
|
// All URLs, not only /api: remove the header again on external redirects.
|
||||||
|
session.defaultSession.webRequest.onBeforeSendHeaders({ urls: ["<all_urls>"] }, (details, callback) => {
|
||||||
|
callback({ requestHeaders: desktopAPIHeaders(details, ownedOrigin, desktopAPISecret, [...trustedAppWindows]) });
|
||||||
|
});
|
||||||
|
chatgpt = createChatGPT({
|
||||||
|
store: fileStore(path.join(app.getPath("userData"), "chatgpt-credentials.json")),
|
||||||
|
openExternal: (target) => shell.openExternal(target),
|
||||||
|
onChange: (value) => {
|
||||||
|
chatgptChild?.send(value);
|
||||||
|
for (const win of BrowserWindow.getAllWindows()) win.webContents.send("lh:chatgpt:changed");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
subscriptions = createSubscriptions({
|
||||||
|
directory: app.getPath("userData"),
|
||||||
|
loadSDK: () => loadSubscriptionSDK(path.dirname(serverEntry())),
|
||||||
|
openExternal: (target) => shell.openExternal(target),
|
||||||
|
onChange: (snapshot) => {
|
||||||
|
subscriptionChild?.send(snapshot);
|
||||||
|
for (const win of BrowserWindow.getAllWindows()) win.webContents.send("lh:subscriptions:changed");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await subscriptions.ready;
|
||||||
log(`starting server: ${serverEntry()}`);
|
log(`starting server: ${serverEntry()}`);
|
||||||
log(`data dir: ${DATA_DIR()}${fs.existsSync(AUTH_FILE()) ? " · auth.json: present" : " · auth.json: ABSENT (no BYOK keys yet)"}`);
|
log(`data dir: ${DATA_DIR()}${fs.existsSync(AUTH_FILE()) ? " · auth.json: present" : " · auth.json: ABSENT (no BYOK keys yet)"}`);
|
||||||
await startServer(new URL(url).port);
|
await startServer(new URL(url).port);
|
||||||
@@ -572,7 +640,31 @@ async function main() {
|
|||||||
log(`dev mode: ${devUrl}`);
|
log(`dev mode: ${devUrl}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Renderer controls contain no token/URL/code operation. The separate server
|
||||||
|
// child is the only recipient of credential leases over inherited IPC.
|
||||||
|
ipcMain.handle("lh:chatgpt:control", async (event, command) => {
|
||||||
|
if (!trustedAppWindows.has(BrowserWindow.fromWebContents(event.sender)) || !trustedSender(event, new URL(url).origin, BrowserWindow)) return { error: "Untrusted desktop request." };
|
||||||
|
if (!chatgpt || !serverProcess?.connected) return { available: false, pending: false, activeId: null, accounts: [], error: "ChatGPT sign-in requires the desktop app's private server connection. Browser-only and externally launched development servers keep API-key connections." };
|
||||||
|
return chatGPTControl(command, chatgpt);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("lh:subscriptions:control", async (event, command) => {
|
||||||
|
if (!trustedAppWindows.has(BrowserWindow.fromWebContents(event.sender)) || !trustedSender(event, new URL(url).origin, BrowserWindow)) throw new Error("Untrusted desktop request.");
|
||||||
|
if (!subscriptions || !serverProcess?.connected) {
|
||||||
|
if (command?.op === "status") return [];
|
||||||
|
throw new Error("Subscription sign-in requires the desktop app's private server connection.");
|
||||||
|
}
|
||||||
|
return subscriptionControl(command, subscriptions);
|
||||||
|
});
|
||||||
|
|
||||||
installMenu();
|
installMenu();
|
||||||
|
ipcMain.on("lh:language", (event, language) => {
|
||||||
|
if (!trustedAppWindows.has(BrowserWindow.fromWebContents(event.sender)) || !trustedSender(event, new URL(url).origin, BrowserWindow)) return;
|
||||||
|
if (language !== "en" && language !== "ru") return;
|
||||||
|
if (shellLanguage === language) return;
|
||||||
|
shellLanguage = language;
|
||||||
|
installMenu();
|
||||||
|
});
|
||||||
|
|
||||||
// Auto-update (Wave C): wire the updater before the window so the
|
// Auto-update (Wave C): wire the updater before the window so the
|
||||||
// banner channel is live from first paint; the first check and the 24h
|
// banner channel is live from first paint; the first check and the 24h
|
||||||
@@ -597,7 +689,7 @@ async function main() {
|
|||||||
if (width < target) win.setBounds({ x, y, width: target, height });
|
if (width < target) win.setBounds({ x, y, width: target, height });
|
||||||
});
|
});
|
||||||
app.on("activate", () => {
|
app.on("activate", () => {
|
||||||
if (BrowserWindow.getAllWindows().length === 0) createWindow(url);
|
if (BrowserWindow.getAllWindows().length === 0) { chatgpt?.resume(); subscriptions?.resume(); createWindow(url); }
|
||||||
});
|
});
|
||||||
createWindow(url);
|
createWindow(url);
|
||||||
log(`window open: ${url}`);
|
log(`window open: ${url}`);
|
||||||
|
|||||||
@@ -29,6 +29,36 @@ const { contextBridge, ipcRenderer } = require("electron");
|
|||||||
|
|
||||||
contextBridge.exposeInMainWorld("lh", {
|
contextBridge.exposeInMainWorld("lh", {
|
||||||
platform: process.platform,
|
platform: process.platform,
|
||||||
|
setLanguage: (language) => ipcRenderer.send("lh:language", language),
|
||||||
|
chatgpt: {
|
||||||
|
status: () => ipcRenderer.invoke("lh:chatgpt:control", { op: "status" }),
|
||||||
|
login: (id) => ipcRenderer.invoke("lh:chatgpt:control", { op: "login", id }),
|
||||||
|
cancel: () => ipcRenderer.invoke("lh:chatgpt:control", { op: "cancel" }),
|
||||||
|
select: (id) => ipcRenderer.invoke("lh:chatgpt:control", { op: "select", id }),
|
||||||
|
disconnect: (id) => ipcRenderer.invoke("lh:chatgpt:control", { op: "disconnect", id }),
|
||||||
|
logout: (id) => ipcRenderer.invoke("lh:chatgpt:control", { op: "logout", id }),
|
||||||
|
consent: (enabled) => ipcRenderer.invoke("lh:chatgpt:control", { op: "consent", enabled }),
|
||||||
|
onChange: (cb) => {
|
||||||
|
const listener = () => cb();
|
||||||
|
ipcRenderer.on("lh:chatgpt:changed", listener);
|
||||||
|
return () => ipcRenderer.removeListener("lh:chatgpt:changed", listener);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
subscriptions: {
|
||||||
|
status: () => ipcRenderer.invoke("lh:subscriptions:control", { op: "status" }),
|
||||||
|
login: (providerId, enabled) => ipcRenderer.invoke("lh:subscriptions:control", { op: "login", providerId, enabled }),
|
||||||
|
cancel: (providerId) => ipcRenderer.invoke("lh:subscriptions:control", { op: "cancel", providerId }),
|
||||||
|
respond: (providerId, attemptId, promptId, value) => ipcRenderer.invoke("lh:subscriptions:control", { op: "respond", providerId, attemptId, promptId, value }),
|
||||||
|
open: (providerId, attemptId, actionId) => ipcRenderer.invoke("lh:subscriptions:control", { op: "open", providerId, attemptId, actionId }),
|
||||||
|
retry: (providerId) => ipcRenderer.invoke("lh:subscriptions:control", { op: "retry", providerId }),
|
||||||
|
disconnect: (providerId) => ipcRenderer.invoke("lh:subscriptions:control", { op: "disconnect", providerId }),
|
||||||
|
consent: (providerId, enabled) => ipcRenderer.invoke("lh:subscriptions:control", { op: "consent", providerId, enabled }),
|
||||||
|
onChange: (cb) => {
|
||||||
|
const listener = () => cb();
|
||||||
|
ipcRenderer.on("lh:subscriptions:changed", listener);
|
||||||
|
return () => ipcRenderer.removeListener("lh:subscriptions:changed", listener);
|
||||||
|
},
|
||||||
|
},
|
||||||
expandWindow: () => ipcRenderer.send("lh:expand-window"),
|
expandWindow: () => ipcRenderer.send("lh:expand-window"),
|
||||||
onZoom: (cb) => {
|
onZoom: (cb) => {
|
||||||
const listener = (_event, factor) => cb(factor);
|
const listener = (_event, factor) => cb(factor);
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* Preloaded only into the packaged macOS backend, before Next starts.
|
||||||
|
*
|
||||||
|
* Next assigns process.title. In Electron's Node mode on macOS, the native
|
||||||
|
* setter checks in with Launch Services using the main app bundle, creating
|
||||||
|
* an unwanted foreground application registration. Keep title writes in JS
|
||||||
|
* instead: the native process name stays unchanged while JS can read back
|
||||||
|
* its assigned title. This deliberately does not emulate native truncation
|
||||||
|
* or every coercion detail; assignments use String(value).
|
||||||
|
*
|
||||||
|
* Explicit launch arguments (not NODE_OPTIONS) keep the full-Electron browser
|
||||||
|
* helper untouched. Node-mode fork descendants can inherit this preload.
|
||||||
|
*/
|
||||||
|
if (process.platform === "darwin" && process.env.ELECTRON_RUN_AS_NODE === "1" && process.env.LH_DESKTOP_SERVER === "1") {
|
||||||
|
let title = process.title;
|
||||||
|
Object.defineProperty(process, "title", {
|
||||||
|
configurable: true,
|
||||||
|
enumerable: true,
|
||||||
|
get: () => title,
|
||||||
|
set: (value) => {
|
||||||
|
title = String(value);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"use strict";
|
||||||
|
const { PROVIDERS } = require("./subscription-store.cjs");
|
||||||
|
const { publicError } = require("./subscriptions.cjs");
|
||||||
|
function attachSubscriptionChild(child, controller) {
|
||||||
|
let disposed = false;
|
||||||
|
const send = (packet) => { if (!disposed && child.connected) { try { child.send(packet, () => {}); } catch { /* child exited */ } } };
|
||||||
|
const listener = async (message) => {
|
||||||
|
if (disposed || !child.connected || message?.channel !== "lh:subscriptions" || typeof message.id !== "string") return;
|
||||||
|
const { id, op } = message;
|
||||||
|
try {
|
||||||
|
if (!controller || !["snapshot", "lease"].includes(op)) throw new Error();
|
||||||
|
if (op === "lease" && (typeof message.background !== "boolean" || !validScope(message.scope))) throw new Error();
|
||||||
|
const value = op === "snapshot" ? await controller.snapshot() : await controller.lease(message.scope, message.background);
|
||||||
|
send({ channel: "lh:subscriptions", id, op, ok: true, value });
|
||||||
|
} catch (error) { send({ channel: "lh:subscriptions", id, op, ok: false, error: publicError(error) }); }
|
||||||
|
};
|
||||||
|
child.on("message", listener);
|
||||||
|
return {
|
||||||
|
send(snapshot) { send({ channel: "lh:subscriptions", op: "changed", snapshot }); },
|
||||||
|
dispose() { disposed = true; child.removeListener("message", listener); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function validScope(scope) {
|
||||||
|
return scope && PROVIDERS.includes(scope.providerId) && typeof scope.connectionId === "string" && !!scope.connectionId && typeof scope.selectedModelId === "string" && !!scope.selectedModelId && [scope.epoch, scope.authorityRevision].every((n) => Number.isSafeInteger(n) && n >= 0);
|
||||||
|
}
|
||||||
|
/** Caller authenticates the exact trusted main-frame origin before dispatch. */
|
||||||
|
async function subscriptionControl(command, controller) {
|
||||||
|
try {
|
||||||
|
if (!controller || !command || typeof command !== "object") throw new Error();
|
||||||
|
if (command.op === "status") return await controller.status();
|
||||||
|
if (!PROVIDERS.includes(command.providerId)) throw new Error();
|
||||||
|
const id = command.providerId;
|
||||||
|
switch (command.op) {
|
||||||
|
case "login": return await controller.login(id, command.enabled);
|
||||||
|
case "cancel": return controller.cancel(id);
|
||||||
|
case "respond": return await controller.respond(id, command.attemptId, command.promptId, command.value);
|
||||||
|
case "open": return await controller.open(id, command.attemptId, command.actionId);
|
||||||
|
case "retry": return await controller.retry(id);
|
||||||
|
case "disconnect": return await controller.disconnect(id);
|
||||||
|
case "consent": return await controller.consent(id, command.enabled);
|
||||||
|
default: throw new Error();
|
||||||
|
}
|
||||||
|
} catch (error) { throw new Error(publicError(error)); }
|
||||||
|
}
|
||||||
|
module.exports = { attachSubscriptionChild, subscriptionControl, validScope };
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"use strict";
|
||||||
|
const fs = require("node:fs/promises");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { randomUUID } = require("node:crypto");
|
||||||
|
|
||||||
|
const PROVIDERS = ["github-copilot", "xai"];
|
||||||
|
const failure = (code) => Object.assign(new Error(code), { code });
|
||||||
|
const nonempty = (value) => typeof value === "string" && value.length > 0 && !/[\x00-\x20\x7f]/.test(value);
|
||||||
|
function validateCredential(providerId, credential) {
|
||||||
|
if (!credential || credential.type !== "oauth" || !nonempty(credential.access) || !nonempty(credential.refresh) || !Number.isFinite(credential.expires) || credential.expires < 0) throw failure("storage");
|
||||||
|
if (providerId === "github-copilot") {
|
||||||
|
if (credential.enterpriseUrl !== undefined && !safeDomain(credential.enterpriseUrl)) throw failure("storage");
|
||||||
|
if (credential.availableModelIds !== undefined && (!Array.isArray(credential.availableModelIds) || !credential.availableModelIds.every(nonempty))) throw failure("storage");
|
||||||
|
}
|
||||||
|
return credential;
|
||||||
|
}
|
||||||
|
function safeDomain(value) {
|
||||||
|
if (typeof value !== "string" || !value || /[\s/:@?#\\]/.test(value)) return false;
|
||||||
|
try { return new URL(`https://${value}`).hostname === value; } catch { return false; }
|
||||||
|
}
|
||||||
|
function safeHttps(value) {
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
return url.protocol === "https:" && !url.username && !url.password;
|
||||||
|
} catch { return false; }
|
||||||
|
}
|
||||||
|
function validateState(providerId, state) {
|
||||||
|
if (!PROVIDERS.includes(providerId) || !state || state.version !== 1 || typeof state.allowBackgroundUse !== "boolean") throw failure("storage");
|
||||||
|
const hasId = Object.hasOwn(state, "connectionId");
|
||||||
|
if (hasId !== Object.hasOwn(state, "credential") || (hasId && !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(state.connectionId))) throw failure("storage");
|
||||||
|
if (hasId) validateCredential(providerId, state.credential);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
/** Main-process-only, one independent owner-only file per provider. No recovery writes on corruption. */
|
||||||
|
function fileStore(file, providerId, io = fs) {
|
||||||
|
return {
|
||||||
|
async read() {
|
||||||
|
try {
|
||||||
|
const stat = await io.lstat(file);
|
||||||
|
if (!stat.isFile() || (process.platform !== "win32" && ((stat.mode & 0o077) !== 0 || (typeof process.getuid === "function" && stat.uid !== process.getuid())))) throw failure("storage");
|
||||||
|
return validateState(providerId, JSON.parse(await io.readFile(file, "utf8")));
|
||||||
|
}
|
||||||
|
catch (error) { if (error.code === "ENOENT") return { version: 1, allowBackgroundUse: true }; throw failure("storage"); }
|
||||||
|
},
|
||||||
|
async write(state) {
|
||||||
|
validateState(providerId, state);
|
||||||
|
const temporary = `${file}.${randomUUID()}.tmp`;
|
||||||
|
let handle;
|
||||||
|
try {
|
||||||
|
await io.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
||||||
|
handle = await io.open(temporary, "wx", 0o600);
|
||||||
|
await handle.writeFile(`${JSON.stringify(state)}\n`);
|
||||||
|
await handle.sync(); await handle.close(); handle = null;
|
||||||
|
await io.rename(temporary, file);
|
||||||
|
if (process.platform !== "win32") {
|
||||||
|
const directory = await io.open(path.dirname(file), "r");
|
||||||
|
try { await directory.sync(); } finally { await directory.close(); }
|
||||||
|
}
|
||||||
|
} catch { throw failure("storage"); }
|
||||||
|
finally { await handle?.close().catch(() => {}); await io.unlink(temporary).catch(() => {}); }
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
module.exports = { PROVIDERS, failure, validateCredential, validateState, safeDomain, safeHttps, fileStore };
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
"use strict";
|
||||||
|
const { randomUUID } = require("node:crypto");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { createRequire, registerHooks } = require("node:module");
|
||||||
|
const { pathToFileURL } = require("node:url");
|
||||||
|
const { PROVIDERS, fileStore, validateCredential, safeHttps, failure } = require("./subscription-store.cjs");
|
||||||
|
|
||||||
|
const LABELS = { "github-copilot": "GitHub Copilot", xai: "Grok" };
|
||||||
|
const APIS = new Set(["openai-responses", "openai-completions", "anthropic-messages"]);
|
||||||
|
const clone = (value) => value === undefined ? undefined : JSON.parse(JSON.stringify(value));
|
||||||
|
const MESSAGES = {
|
||||||
|
storage: "The saved connection could not be read or written safely. Reopen the app or reconnect after resolving the credential-file problem.",
|
||||||
|
login: "Sign-in did not complete. Try connecting again.",
|
||||||
|
refresh: "The connection could not be refreshed. Retry or reconnect in Providers.",
|
||||||
|
cancel: "Sign-in cancelled.",
|
||||||
|
unavailable: "This subscription connection is unavailable. Reconnect or explicitly choose another connection.",
|
||||||
|
background: "Background use is off for this connection. Automatic follow-up remains deferred in its queue.",
|
||||||
|
stale: "The connection or model catalog changed. Select an available model and retry.",
|
||||||
|
interaction: "This sign-in action is no longer available.",
|
||||||
|
};
|
||||||
|
function publicError(error) { return MESSAGES[error?.code] ?? MESSAGES.unavailable; }
|
||||||
|
/** Resolve the SAME SDK installation as the standalone server, through public exports. */
|
||||||
|
async function loadSubscriptionSDK(baseDirectory) {
|
||||||
|
const resolve = createRequire(path.join(baseDirectory, "package.json"));
|
||||||
|
const load = (id) => {
|
||||||
|
// Pi exports are import-only. Resolve the public export with ESM
|
||||||
|
// conditions, then remove this synchronous, exact-specifier hook
|
||||||
|
// BEFORE evaluation; no private package path or global hook remains.
|
||||||
|
const hook = registerHooks({ resolve(specifier, context, next) {
|
||||||
|
return next(specifier, specifier === id ? { ...context, conditions: [...context.conditions, "import"] } : context);
|
||||||
|
} });
|
||||||
|
let file;
|
||||||
|
try { file = resolve.resolve(id); } finally { hook.deregister(); }
|
||||||
|
return import(pathToFileURL(file).href);
|
||||||
|
};
|
||||||
|
const [sdk, copilot, xai] = await Promise.all([load("@earendil-works/pi-ai"), load("@earendil-works/pi-ai/providers/github-copilot"), load("@earendil-works/pi-ai/providers/xai")]);
|
||||||
|
return { ...sdk, ...copilot, ...xai };
|
||||||
|
}
|
||||||
|
function validEndpoint(providerId, auth, credential) {
|
||||||
|
if (!auth || (!(typeof auth.apiKey === "string" && auth.apiKey.length > 0) && !Object.entries(auth.headers ?? {}).some(([key, value]) => ["authorization", "x-api-key", "api-key"].includes(key.toLowerCase()) && typeof value === "string" && value.trim().length > 0))) return false;
|
||||||
|
const value = auth.baseUrl ?? (providerId === "xai" ? "https://api.x.ai/v1" : "https://api.individual.githubcopilot.com");
|
||||||
|
if (!safeHttps(value)) return false;
|
||||||
|
const url = new URL(value);
|
||||||
|
if (providerId === "xai") return url.origin === "https://api.x.ai" && url.pathname.replace(/\/$/, "") === "/v1" && !url.search && !url.hash;
|
||||||
|
const domain = credential?.enterpriseUrl;
|
||||||
|
return !url.search && !url.hash && url.pathname === "/" && (!url.port || url.port === "443") && (url.hostname.endsWith(".githubcopilot.com") || (domain && (url.hostname === domain || url.hostname.endsWith(`.${domain}`))));
|
||||||
|
}
|
||||||
|
/** Main owns durable credentials and refresh. Everything returned by status/snapshot is explicitly projected. */
|
||||||
|
function createSubscriptions(options) {
|
||||||
|
let sdk;
|
||||||
|
let closed = false, paused = false;
|
||||||
|
const entries = new Map(PROVIDERS.map((providerId) => [providerId, {
|
||||||
|
providerId, state: { version: 1, allowBackgroundUse: true }, disk: options.stores?.[providerId] ?? fileStore(path.join(options.directory, `${providerId}-credentials.json`), providerId),
|
||||||
|
epoch: 0, authorityRevision: 0, revision: 0, tail: Promise.resolve(), fatal: false, blocked: false, ready: false, error: undefined, authFailed: false, attempt: null, catalog: [], endpoint: null,
|
||||||
|
}]));
|
||||||
|
const entryFor = (id) => { const entry = entries.get(id); if (!entry) throw failure("unavailable"); return entry; };
|
||||||
|
function serialized(entry, fn) { const result = entry.tail.then(fn); entry.tail = result.catch(() => {}); return result; }
|
||||||
|
function statusOf(entry) {
|
||||||
|
const available = entry.ready && !closed && !paused && !entry.fatal;
|
||||||
|
const attempt = entry.attempt;
|
||||||
|
return {
|
||||||
|
providerId: entry.providerId, available, connected: !!(available && entry.state.credential && !entry.blocked && !entry.authFailed), pending: !!attempt, committing: attempt?.committing === true,
|
||||||
|
allowBackgroundUse: entry.state.allowBackgroundUse, connectionId: entry.state.connectionId ?? null,
|
||||||
|
epoch: entry.epoch, authorityRevision: entry.authorityRevision, revision: entry.revision,
|
||||||
|
...(entry.error ? { error: entry.error } : {}),
|
||||||
|
...(attempt ? { interaction: clone(attempt.public) } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function snapshotNow() {
|
||||||
|
return { statuses: [...entries.values()].map(statusOf), models: Object.fromEntries([...entries.values()].map((entry) => [entry.providerId, statusOf(entry).connected ? clone(entry.catalog) : []])) };
|
||||||
|
}
|
||||||
|
function changed(entry) { entry.revision++; try { options.onChange?.(snapshotNow()); } catch { /* observer cannot break credential writes */ } }
|
||||||
|
function catalog(entry) {
|
||||||
|
let models = entry.provider?.getModels().filter((model) => APIS.has(model.api)) ?? [];
|
||||||
|
if (!entry.state.credential) models = [];
|
||||||
|
else if (entry.providerId === "github-copilot" && !Array.isArray(entry.state.credential.availableModelIds)) models = [];
|
||||||
|
else if (entry.provider?.filterModels) models = [...entry.provider.filterModels(models, entry.state.credential)];
|
||||||
|
if (JSON.stringify(models) !== JSON.stringify(entry.catalog)) { entry.catalog = models; entry.authorityRevision++; }
|
||||||
|
}
|
||||||
|
async function updateEndpoint(entry) {
|
||||||
|
let endpoint = null;
|
||||||
|
if (entry.state.credential) {
|
||||||
|
const auth = await entry.provider.auth.oauth.toAuth(entry.state.credential);
|
||||||
|
if (!validEndpoint(entry.providerId, auth, entry.state.credential)) throw failure("storage");
|
||||||
|
endpoint = auth.baseUrl ?? (entry.providerId === "xai" ? "https://api.x.ai/v1" : "https://api.individual.githubcopilot.com");
|
||||||
|
}
|
||||||
|
if (entry.endpoint !== endpoint) { entry.endpoint = endpoint; entry.authorityRevision++; }
|
||||||
|
}
|
||||||
|
function storageFailed(entry) { entry.fatal = true; entry.blocked = true; entry.epoch++; entry.error = MESSAGES.storage; changed(entry); }
|
||||||
|
async function save(entry, state) {
|
||||||
|
try { await entry.disk.write(state); } catch { storageFailed(entry); throw failure("storage"); }
|
||||||
|
entry.state = clone(state);
|
||||||
|
try { await updateEndpoint(entry); catalog(entry); } catch { storageFailed(entry); throw failure("storage"); }
|
||||||
|
changed(entry);
|
||||||
|
}
|
||||||
|
const ready = (async () => {
|
||||||
|
try { sdk = options.sdk ?? await options.loadSDK(); }
|
||||||
|
catch { for (const entry of entries.values()) { entry.error = MESSAGES.unavailable; entry.fatal = true; changed(entry); } return; }
|
||||||
|
await Promise.all([...entries.values()].map(async (entry) => {
|
||||||
|
try {
|
||||||
|
entry.state = clone(await entry.disk.read());
|
||||||
|
const native = entry.providerId === "github-copilot" ? sdk.githubCopilotProvider() : sdk.xaiProvider();
|
||||||
|
// No API-key auth method exists in this collection: ambient keys cannot become a fallback.
|
||||||
|
entry.provider = { ...native, auth: { oauth: native.auth.oauth } };
|
||||||
|
const store = {
|
||||||
|
read: async (id) => id === entry.providerId ? clone(entry.state.credential) : undefined,
|
||||||
|
list: async () => entry.state.credential ? [{ providerId: entry.providerId, type: "oauth" }] : [],
|
||||||
|
modify: (id, fn, operation) => serialized(entry, async () => {
|
||||||
|
if (id !== entry.providerId || entry.fatal || closed || paused || operation?.signal?.aborted) throw failure("unavailable");
|
||||||
|
const next = await fn(clone(entry.state.credential));
|
||||||
|
// A returned rotation must be saved even if the app paused while
|
||||||
|
// refresh was in flight. Admission is rechecked after resolution;
|
||||||
|
// queued logout deletes only after this serialized durable write.
|
||||||
|
if (next !== undefined && JSON.stringify(next) !== JSON.stringify(entry.state.credential)) {
|
||||||
|
try { validateCredential(id, next); } catch { storageFailed(entry); throw failure("storage"); }
|
||||||
|
await save(entry, { ...entry.state, credential: next });
|
||||||
|
}
|
||||||
|
return clone(entry.state.credential);
|
||||||
|
}),
|
||||||
|
delete: (id) => serialized(entry, async () => {
|
||||||
|
if (id !== entry.providerId || entry.fatal) throw failure("storage");
|
||||||
|
await save(entry, { version: 1, allowBackgroundUse: entry.state.allowBackgroundUse });
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
entry.models = sdk.createModels({ credentials: store }); entry.models.setProvider(entry.provider);
|
||||||
|
await updateEndpoint(entry); entry.ready = true; catalog(entry); changed(entry);
|
||||||
|
} catch { entry.fatal = true; entry.error = MESSAGES.storage; changed(entry); }
|
||||||
|
}));
|
||||||
|
})();
|
||||||
|
function current(entry, scope) {
|
||||||
|
return statusOf(entry).connected && scope?.providerId === entry.providerId && scope.connectionId === entry.state.connectionId && scope.epoch === entry.epoch && scope.authorityRevision === entry.authorityRevision && entry.catalog.some((m) => m.id === scope.selectedModelId);
|
||||||
|
}
|
||||||
|
async function consent(providerId, enabled) {
|
||||||
|
await ready; const entry = entryFor(providerId);
|
||||||
|
if (typeof enabled !== "boolean" || entry.fatal || closed || paused) throw failure("unavailable");
|
||||||
|
await serialized(entry, async () => { if (entry.state.allowBackgroundUse !== enabled) await save(entry, { ...entry.state, allowBackgroundUse: enabled }); });
|
||||||
|
}
|
||||||
|
async function login(providerId, enabled) {
|
||||||
|
await ready; const entry = entryFor(providerId);
|
||||||
|
if ((enabled !== undefined && typeof enabled !== "boolean") || entry.fatal || closed || paused || !entry.ready) throw failure("unavailable");
|
||||||
|
if ([...entries.values()].some((e) => e.attempt)) throw failure("interaction");
|
||||||
|
const abort = new AbortController();
|
||||||
|
const attempt = { id: randomUUID(), abort, committing: false, actions: new Map(), prompt: null, public: {} };
|
||||||
|
attempt.public = { attemptId: attempt.id }; entry.attempt = attempt; entry.error = undefined; changed(entry);
|
||||||
|
const active = () => entry.attempt === attempt && !abort.signal.aborted && !closed && !paused;
|
||||||
|
const addBrowser = (value) => {
|
||||||
|
if (!safeHttps(value)) throw failure("interaction");
|
||||||
|
const id = randomUUID(); attempt.actions.set(id, value);
|
||||||
|
const url = new URL(value); attempt.public.browser = { id, url: `${url.origin}${url.pathname}` };
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const staging = sdk.createModels(); staging.setProvider(entry.provider);
|
||||||
|
const credential = await staging.login(providerId, "oauth", {
|
||||||
|
signal: abort.signal,
|
||||||
|
notify: (event) => {
|
||||||
|
if (!active()) return;
|
||||||
|
if (event.type === "auth_url") addBrowser(event.url);
|
||||||
|
if (event.type === "device_code") { addBrowser(event.verificationUri); attempt.public.userCode = event.userCode; }
|
||||||
|
// Native provider diagnostics are never forwarded. Public prompt wording comes from SDK-owned prompts.
|
||||||
|
attempt.public.progress = event.type === "device_code" || event.type === "auth_url" ? "Complete sign-in in your browser." : "Waiting for the provider…";
|
||||||
|
changed(entry);
|
||||||
|
},
|
||||||
|
prompt: (prompt) => new Promise((resolve, reject) => {
|
||||||
|
if (!active() || prompt.signal?.aborted) { reject(failure("cancel")); return; }
|
||||||
|
const id = randomUUID();
|
||||||
|
const stop = () => { cleanup(); reject(failure("cancel")); };
|
||||||
|
const cleanup = () => { abort.signal.removeEventListener("abort", stop); prompt.signal?.removeEventListener("abort", stop); if (attempt.prompt?.id === id) { attempt.prompt = null; delete attempt.public.prompt; } };
|
||||||
|
attempt.prompt = { id, resolve: (value) => { cleanup(); resolve(value); }, reject: stop };
|
||||||
|
attempt.public.prompt = { id, type: prompt.type, message: prompt.message, ...(prompt.placeholder ? { placeholder: prompt.placeholder } : {}), ...(prompt.type === "select" ? { options: clone(prompt.options) } : {}) };
|
||||||
|
abort.signal.addEventListener("abort", stop, { once: true }); prompt.signal?.addEventListener("abort", stop, { once: true }); changed(entry);
|
||||||
|
}),
|
||||||
|
}, { agentName: "LearnHarness" });
|
||||||
|
if (!active()) throw failure("cancel"); validateCredential(providerId, credential);
|
||||||
|
const auth = await entry.provider.auth.oauth.toAuth(credential);
|
||||||
|
if (!validEndpoint(providerId, auth, credential) || !active()) throw failure("login");
|
||||||
|
await serialized(entry, async () => {
|
||||||
|
if (!active() || entry.fatal) throw failure("cancel");
|
||||||
|
attempt.committing = true; entry.blocked = true; entry.epoch++; changed(entry);
|
||||||
|
await save(entry, { version: 1, allowBackgroundUse: enabled ?? entry.state.allowBackgroundUse, connectionId: randomUUID(), credential });
|
||||||
|
entry.blocked = false; entry.authFailed = false; entry.error = undefined;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (active() && !entry.fatal) entry.error = error?.code === "cancel" ? undefined : MESSAGES.login;
|
||||||
|
if (!abort.signal.aborted) throw failure(entry.fatal ? "storage" : "login");
|
||||||
|
} finally { attempt.prompt?.reject(); if (entry.attempt === attempt) { entry.attempt = null; changed(entry); } }
|
||||||
|
}
|
||||||
|
function cancel(providerId) { const entry = entryFor(providerId); if (entry.attempt && !entry.attempt.committing) { entry.attempt.abort.abort(); entry.attempt = null; if (entry.authFailed && !entry.error) entry.error = MESSAGES.refresh; changed(entry); } }
|
||||||
|
async function resolveAuth(entry, modelId) {
|
||||||
|
if (entry.fatal || closed || paused || !entry.state.credential) throw failure("unavailable");
|
||||||
|
const connectionId = entry.state.connectionId, epoch = entry.epoch;
|
||||||
|
const unchanged = () => connectionId === entry.state.connectionId && epoch === entry.epoch && !entry.blocked && !closed && !paused;
|
||||||
|
try {
|
||||||
|
const model = entry.provider.getModels().find((m) => m.id === modelId);
|
||||||
|
if (!model) throw failure("stale");
|
||||||
|
const resolved = await entry.models.getAuth(model);
|
||||||
|
if (!unchanged()) throw failure("stale");
|
||||||
|
if (!validEndpoint(entry.providerId, resolved?.auth, entry.state.credential)) throw failure("unavailable");
|
||||||
|
return {
|
||||||
|
...(typeof resolved.auth.apiKey === "string" ? { apiKey: resolved.auth.apiKey } : {}),
|
||||||
|
...(resolved.auth.headers ? { headers: Object.fromEntries(Object.entries(resolved.auth.headers).filter(([, value]) => value === null || typeof value === "string")) } : {}),
|
||||||
|
...(typeof resolved.auth.baseUrl === "string" ? { baseUrl: resolved.auth.baseUrl } : {}),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (!entry.fatal && unchanged()) { entry.authFailed = true; entry.error = MESSAGES.refresh; entry.epoch++; changed(entry); }
|
||||||
|
throw failure(entry.fatal ? "storage" : error?.code === "stale" ? "stale" : "refresh");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ready,
|
||||||
|
async snapshot() { await ready; return snapshotNow(); },
|
||||||
|
async status() { await ready; return snapshotNow().statuses; },
|
||||||
|
login, consent, cancel,
|
||||||
|
async respond(providerId, attemptId, promptId, value) {
|
||||||
|
const entry = entryFor(providerId), attempt = entry.attempt;
|
||||||
|
if (!attempt || attempt.id !== attemptId || attempt.prompt?.id !== promptId || typeof value !== "string" || value.length > 4096) throw failure("interaction");
|
||||||
|
const prompt = attempt.public.prompt;
|
||||||
|
if (prompt.type === "select" && !prompt.options.some((option) => option.id === value)) throw failure("interaction");
|
||||||
|
attempt.prompt.resolve(value); changed(entry);
|
||||||
|
},
|
||||||
|
async open(providerId, attemptId, actionId) {
|
||||||
|
const attempt = entryFor(providerId).attempt;
|
||||||
|
const target = attempt?.id === attemptId ? attempt.actions.get(actionId) : undefined;
|
||||||
|
if (!target || !safeHttps(target)) throw failure("interaction");
|
||||||
|
try { await options.openExternal(target); } catch { throw failure("interaction"); }
|
||||||
|
},
|
||||||
|
async lease(scope, background) {
|
||||||
|
await ready; const entry = entryFor(scope?.providerId);
|
||||||
|
if (!current(entry, scope)) throw failure("stale");
|
||||||
|
if (background && !entry.state.allowBackgroundUse) throw failure("background");
|
||||||
|
const auth = await resolveAuth(entry, scope.selectedModelId);
|
||||||
|
if (!current(entry, scope)) throw failure("stale");
|
||||||
|
if (background && !entry.state.allowBackgroundUse) throw failure("background");
|
||||||
|
return { scope: clone(scope), auth: clone(auth) };
|
||||||
|
},
|
||||||
|
async retry(providerId) {
|
||||||
|
await ready; const entry = entryFor(providerId);
|
||||||
|
const model = entry.catalog[0]; if (!model || entry.fatal || entry.blocked) throw failure("unavailable");
|
||||||
|
await resolveAuth(entry, model.id); entry.authFailed = false; entry.error = undefined; changed(entry);
|
||||||
|
},
|
||||||
|
async disconnect(providerId) {
|
||||||
|
await ready; const entry = entryFor(providerId); cancel(providerId);
|
||||||
|
if (entry.fatal) throw failure("storage");
|
||||||
|
entry.blocked = true; entry.epoch++; changed(entry);
|
||||||
|
await entry.models.logout(providerId); entry.blocked = false; entry.authFailed = false; entry.error = undefined; changed(entry);
|
||||||
|
},
|
||||||
|
pause() { paused = true; for (const entry of entries.values()) { cancel(entry.providerId); entry.epoch++; changed(entry); } },
|
||||||
|
resume() { if (closed) return; paused = false; for (const entry of entries.values()) changed(entry); },
|
||||||
|
close() { closed = true; for (const entry of entries.values()) { cancel(entry.providerId); entry.epoch++; changed(entry); } },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
module.exports = { createSubscriptions, loadSubscriptionSDK, publicError, validEndpoint, LABELS };
|
||||||
+21
-4
@@ -17,7 +17,8 @@ const nextConfig: NextConfig = {
|
|||||||
// into the DMG (the packaged app never reads it: the shell passes
|
// into the DMG (the packaged app never reads it: the shell passes
|
||||||
// LH_AUTH_FILE → userData). Excluded here; build-server.mjs also REFUSES
|
// LH_AUTH_FILE → userData). Excluded here; build-server.mjs also REFUSES
|
||||||
// to package any standalone still containing it. .env* belt-and-braces
|
// to package any standalone still containing it. .env* belt-and-braces
|
||||||
// the same class (not observed swept, same seam).
|
// the same class. Next also copies loaded bare .env after tracing;
|
||||||
|
// the build-server refusal remains required even with these excludes.
|
||||||
//
|
//
|
||||||
// ⚠ EXCLUDE PATTERNS ARE LOAD-BEARING IN WAYS THE DOCS DON'T DESCRIBE
|
// ⚠ EXCLUDE PATTERNS ARE LOAD-BEARING IN WAYS THE DOCS DON'T DESCRIBE
|
||||||
// (empirically established 2026-10-08): adding dir excludes like
|
// (empirically established 2026-10-08): adding dir excludes like
|
||||||
@@ -26,8 +27,11 @@ const nextConfig: NextConfig = {
|
|||||||
// under their dist/ — the traced copy collapsed to a bare package.json
|
// under their dist/ — the traced copy collapsed to a bare package.json
|
||||||
// and the packaged server died on ERR_MODULE_NOT_FOUND; openai/
|
// and the packaged server died on ERR_MODULE_NOT_FOUND; openai/
|
||||||
// @anthropic-ai — pi-ai's deps — vanished from the trace entirely).
|
// @anthropic-ai — pi-ai's deps — vanished from the trace entirely).
|
||||||
// Anchored "./"-prefixed forms behave the same. Only the three entries
|
// Anchored "./"-prefixed forms behave the same. **/data/**/* fixes
|
||||||
// below are verified safe (complete externals trace + clean payload).
|
// suffix matching in Next's JavaScript collector, but NOT its native
|
||||||
|
// Turbopack tracer: that still prunes gcp-metadata and its private deps.
|
||||||
|
// Keep the narrow closure workaround below AND the native runtime gate.
|
||||||
|
// Real SDK data/ directories still need their explicit includes too.
|
||||||
// The recursive-DMG payload (repo dist/ swept into the bundle, shipping
|
// The recursive-DMG payload (repo dist/ swept into the bundle, shipping
|
||||||
// the previous build inside the next) is instead prevented by the
|
// the previous build inside the next) is instead prevented by the
|
||||||
// desktop:dist clean step AND targeted post-trace curation in
|
// desktop:dist clean step AND targeted post-trace curation in
|
||||||
@@ -37,7 +41,7 @@ const nextConfig: NextConfig = {
|
|||||||
// stay intact, and the script guards against leaked data/keys.
|
// stay intact, and the script guards against leaked data/keys.
|
||||||
outputFileTracingExcludes: {
|
outputFileTracingExcludes: {
|
||||||
"*": [
|
"*": [
|
||||||
"data/**/*",
|
"**/data/**/*",
|
||||||
"auth.json",
|
"auth.json",
|
||||||
".env*",
|
".env*",
|
||||||
],
|
],
|
||||||
@@ -70,7 +74,20 @@ const nextConfig: NextConfig = {
|
|||||||
// per-platform-binary story as the canvas above (one per build).
|
// per-platform-binary story as the canvas above (one per build).
|
||||||
"node_modules/@resvg/resvg-js-*/**/*",
|
"node_modules/@resvg/resvg-js-*/**/*",
|
||||||
"node_modules/@earendil-works/pi-ai/dist/providers/data/**/*",
|
"node_modules/@earendil-works/pi-ai/dist/providers/data/**/*",
|
||||||
|
// Public native OAuth factories load these flows through variable imports.
|
||||||
|
// Electron main uses the same standalone SDK; the offline runtime gate
|
||||||
|
// evaluates their real toAuth paths without login or provider requests.
|
||||||
|
"node_modules/@earendil-works/pi-ai/dist/auth/oauth/{load,github-copilot,xai,device-code}.js",
|
||||||
|
"node_modules/@earendil-works/pi-ai/dist/utils/sleep.js",
|
||||||
"node_modules/@earendil-works/pi-agent-core/dist/**/*.json",
|
"node_modules/@earendil-works/pi-agent-core/dist/**/*.json",
|
||||||
|
// Next 16.3.2 native Turbopack applies unanchored excludes while
|
||||||
|
// traversing modules; **/data/**/* still prunes gcp-metadata and
|
||||||
|
// its private runtime chain. Includes are applied after traversal.
|
||||||
|
// This is the verified GoogleAuth closure, not a general dep copy;
|
||||||
|
// the offline native Pi gate must catch future SDK closure changes.
|
||||||
|
"node_modules/gcp-metadata/**/*",
|
||||||
|
"node_modules/json-bigint/**/*",
|
||||||
|
"node_modules/bignumber.js/**/*",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
// Framework-free core lives in src/ (plain TS, imported by routes and CLI alike).
|
// Framework-free core lives in src/ (plain TS, imported by routes and CLI alike).
|
||||||
|
|||||||
Generated
+12
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "learnharness",
|
"name": "learnharness",
|
||||||
"version": "0.3.4",
|
"version": "0.5.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "learnharness",
|
"name": "learnharness",
|
||||||
"version": "0.3.4",
|
"version": "0.5.2",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@earendil-works/pi-agent-core": "1.4.0",
|
"@earendil-works/pi-agent-core": "1.4.0",
|
||||||
@@ -31,6 +31,7 @@
|
|||||||
"dagre-d3-es": "7.0.14",
|
"dagre-d3-es": "7.0.14",
|
||||||
"electron-updater": "^6.8.9",
|
"electron-updater": "^6.8.9",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
|
"jose": "^6.2.12",
|
||||||
"katex": "0.18.2",
|
"katex": "0.18.2",
|
||||||
"linkedom": "^0.18.13",
|
"linkedom": "^0.18.13",
|
||||||
"lucide-react": "^1.33.0",
|
"lucide-react": "^1.33.0",
|
||||||
@@ -11655,6 +11656,15 @@
|
|||||||
"jiti": "lib/jiti-cli.mjs"
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jose": {
|
||||||
|
"version": "6.2.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz",
|
||||||
|
"integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/js-yaml": {
|
"node_modules/js-yaml": {
|
||||||
"version": "4.3.2",
|
"version": "4.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user