Make Thinkloom native-only

This commit is contained in:
2026-07-17 07:58:10 -07:00
parent 1d42788505
commit 7866081c78
38 changed files with 4147 additions and 14787 deletions
+5 -45
View File
@@ -1,48 +1,8 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. /node_modules/
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/.vinext/
/out/
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
next-env.d.ts
/dist/ /dist/
/.wrangler/
/outputs/
/work/
# Thinkloom generated artifacts
/desktop-dist/
/src-tauri/target/ /src-tauri/target/
*.tsbuildinfo *.tsbuildinfo
.env*
npm-debug.log*
.DS_Store
/.wrangler/
-5
View File
@@ -1,5 +0,0 @@
{
"project_id": "appgprj_6a59ce11a2688191a982245d4f9a82db",
"d1": null,
"r2": null
}
+11 -12
View File
@@ -1,26 +1,27 @@
# Thinkloom implementation status # Thinkloom implementation status
Status date: 2026-07-16 Status date: 2026-07-17
## Completed locally ## Completed locally
- M0 engineering baseline: shared React/TypeScript interface, Tauri 2 shell, typed command errors, Windows packaging configuration, browser/Sites companion, build/type/lint/unit checks. - M0 engineering baseline: native-only React/TypeScript interface, Tauri 2 shell, typed command errors, Windows packaging configuration, and build/type/lint/unit checks.
- M1 project and provenance foundation: project layout, SQLite schema, atomic canonical writes, rotating snapshots, SHA-256 event chain, chain verification, hidden Git checkpoints, canonical rebuild inputs. - M1 project and provenance foundation: project layout, SQLite schema, atomic canonical writes, rotating snapshots, SHA-256 event chain, chain verification, hidden Git checkpoints, and canonical rebuild inputs.
- M2 provider/privacy boundary: Ollama, OpenAI and compatible request paths; OS vault secrets; provider test; local/cloud status; first-cloud-use approval; retry-preserving failure state. - M2 provider/privacy boundary: Ollama, OpenAI and compatible request paths; OS vault secrets; provider test; local/cloud status; first-cloud-use approval; and retry-preserving failure state.
- M3 ideation and idea curation: typed turns, one focused question, challenge modes, suggestion review, direct save, edits, variants, merge, tags, archive/rejected history, drafting sets. - M3 ideation and idea curation: typed turns, one focused question, challenge modes, suggestion review, direct save, edits, variants, merge, tags, archive/rejected history, and drafting sets.
- M4 drafting workspace: TipTap structured editor, dominant three-panel layout, selective context, staged generation, all acceptance destinations, partial acceptance, manual editing, undo/redo, autosave. - M4 drafting workspace: TipTap structured editor, dominant three-panel layout, selective context, staged generation, all acceptance destinations, partial acceptance, manual editing, undo/redo, and autosave.
- M5 finalization/style/history: editorial action previews, editable style traits and disallowed habits, saved versions, comparison summary, restore, release checkpoints, provenance timeline and contribution relationships. - M5 finalization/style/history: editorial action previews, editable style traits and disallowed habits, saved versions, comparison summary, restore, release checkpoints, provenance timeline, and contribution relationships.
- M7 export/backup/evidence: Markdown, HTML, PDF, text, backup ZIP, evidence ZIP, sanitization disclosure, hashes, manifest, atomic finalization, strict ZIP import validation. - M7 export/backup/evidence: Markdown, HTML, PDF, text, backup ZIP, evidence ZIP, sanitization disclosure, hashes, manifest, atomic finalization, and strict ZIP import validation.
- Product surface: Sites, Next, Vinext, Cloudflare, and browser-companion code and configuration have been removed. Tauri is the sole application target.
## External release gates ## External release gates
These items cannot be honestly certified from this workspace alone and need project-owner decisions or assets: These items cannot be honestly certified from this workspace alone and need project-owner decisions or assets:
1. Voice runtime: provide/approve the faster-whisper model size and Silero VAD ONNX model distribution. The interface currently uses ephemeral browser speech recognition as a functional fallback and retains no audio, but the required bundled local voice pipeline is not yet present. 1. Voice runtime: provide or approve the faster-whisper model size and Silero VAD ONNX model distribution. The interface currently uses ephemeral webview speech recognition as a functional fallback and retains no audio, but the required bundled local voice pipeline is not yet present.
2. Local-model acceptance baseline: confirm the Ollama model used for release testing. The implementation default is `llama3.2`. 2. Local-model acceptance baseline: confirm the Ollama model used for release testing. The implementation default is `llama3.2`.
3. Signing and distribution: provide the Windows code-signing certificate and choose the installer distribution channel. MSI/NSIS configuration is present, but signed release artifacts cannot be produced without credentials. 3. Signing and distribution: provide the Windows code-signing certificate and choose the installer distribution channel. MSI/NSIS configuration is present, but signed release artifacts cannot be produced without credentials.
4. Secondary platforms: decide whether macOS and Linux are part of the first signed release. Their keychain, microphone, installer, and packaging gates require platform runners. 4. Secondary platforms: decide whether macOS and Linux are part of the first signed release. Their keychain, microphone, installer, and packaging gates require platform runners.
5. PDF typography: the native exporter creates a valid dependency-free PDF. Approve whether the release should instead bundle a browser-print engine for richer typography. 5. PDF typography: the native exporter creates a valid dependency-free PDF. Approve whether the release should instead bundle a native print engine for richer typography.
6. Diagnostics policy: confirm whether opt-in diagnostic reports are allowed. Current diagnostics are local, redacted, and exclude prompts, responses, credentials, and audio. 6. Diagnostics policy: confirm whether opt-in diagnostic reports are allowed. Current diagnostics are local, redacted, and exclude prompts, responses, credentials, and audio.
## Remaining hardening ## Remaining hardening
@@ -28,5 +29,3 @@ These items cannot be honestly certified from this workspace alone and need proj
- Run microphone/VAD/transcription integration tests once the approved model assets are supplied. - Run microphone/VAD/transcription integration tests once the approved model assets are supplied.
- Run signed clean-install, upgrade, downgrade-warning, and uninstall tests on each release platform. - Run signed clean-install, upgrade, downgrade-warning, and uninstall tests on each release platform.
- Run full 20,000-word interaction and fault-injection profiling on packaged release hardware. - Run full 20,000-word interaction and fault-injection profiling on packaged release hardware.
- Resolve the upstream moderate PostCSS advisory inherited by the pinned Next/Vinext preview toolchain when a compatible patch is available. Native packaged assets do not execute the Sites server dependency.
- Deploy the Sites companion after a deployment credential is issued by the connected Sites service.
+5 -9
View File
@@ -1,16 +1,13 @@
# Thinkloom # Thinkloom
Thinkloom is a desktop-first, local-first writing studio that helps one writer move from conversation to ideas, draft, revision, and a versioned release while preserving an inspectable creative-process record. Thinkloom is a native, local-first writing studio that helps one writer move from conversation to ideas, draft, revision, and a versioned release while preserving an inspectable creative-process record.
This repository contains two builds of the same React interface: The application is built with Tauri 2, React, TypeScript, Rust, SQLite, canonical Markdown/JSON/JSONL files, hidden Git checkpoints, operating-system credentials, and atomic exports. There is no hosted or browser companion build.
- a Tauri 2 desktop application backed by Rust, SQLite, canonical Markdown/JSON/JSONL files, hidden Git checkpoints, operating-system credentials, and atomic exports;
- a Sites companion build for product preview and browser-based evaluation.
## Implemented workflow ## Implemented workflow
- Reversible Ideation, Drafting, and Finalization phases - Reversible Ideation, Drafting, and Finalization phases
- Typed conversation, challenge levels, push-to-talk browser transcription, and optional visible speech output - Typed conversation, challenge levels, push-to-talk transcription, and optional visible speech output
- Suggested ideas with explicit accept/reject, editing, variants, archiving, source links, drafting sets, and merges - Suggested ideas with explicit accept/reject, editing, variants, archiving, source links, drafting sets, and merges
- TipTap/ProseMirror structured manuscript editor with canonical Markdown round-tripping, undo/redo, headings, lists, selection replacement, and cursor insertion - TipTap/ProseMirror structured manuscript editor with canonical Markdown round-tripping, undo/redo, headings, lists, selection replacement, and cursor insertion
- Persisted preview-first generation states with retry-safe provider errors and partial acceptance - Persisted preview-first generation states with retry-safe provider errors and partial acceptance
@@ -30,9 +27,8 @@ Requirements: Node.js 22.13 or newer, Rust 1.77.2 or newer, Git, and the Windows
```powershell ```powershell
npm install npm install
npm run dev npm run dev
npm run desktop:dev
npm run build npm run build
npm run desktop:build npm run tauri -- dev
npm run tauri -- build npm run tauri -- build
``` ```
@@ -49,7 +45,7 @@ cargo test
## Project storage ## Project storage
A desktop project is self-contained. Canonical files live under `manuscript/`, `ideas/`, `conversations/`, `provenance/`, and `style/`. Live SQLite state and rotating snapshots are under `.thinkloom/` and are excluded from the project’s hidden Git history. Audio retention is always false; no audio file extension is created by the native service. A Thinkloom project is self-contained. Canonical files live under `manuscript/`, `ideas/`, `conversations/`, `provenance/`, and `style/`. Live SQLite state and rotating snapshots are under `.thinkloom/` and are excluded from the project's hidden Git history. Audio retention is always false; no audio file extension is created by the native service.
## Provider setup ## Provider setup
-86
View File
@@ -1,86 +0,0 @@
import { headers } from "next/headers";
import { redirect } from "next/navigation";
export type ChatGPTUser = {
displayName: string;
email: string;
fullName: string | null;
};
const USER_EMAIL_HEADER = "oai-authenticated-user-email";
const USER_FULL_NAME_HEADER = "oai-authenticated-user-full-name";
const USER_FULL_NAME_ENCODING_HEADER =
"oai-authenticated-user-full-name-encoding";
const PERCENT_ENCODED_UTF8 = "percent-encoded-utf-8";
const SIGN_IN_PATH = "/signin-with-chatgpt";
const SIGN_OUT_PATH = "/signout-with-chatgpt";
const CALLBACK_PATH = "/callback";
export async function getChatGPTUser(): Promise<ChatGPTUser | null> {
const requestHeaders = await headers();
const email = requestHeaders.get(USER_EMAIL_HEADER);
if (!email) return null;
const encodedFullName = requestHeaders.get(USER_FULL_NAME_HEADER);
const fullName =
encodedFullName &&
requestHeaders.get(USER_FULL_NAME_ENCODING_HEADER) === PERCENT_ENCODED_UTF8
? safeDecodeURIComponent(encodedFullName)
: null;
return {
displayName: fullName ?? email,
email,
fullName,
};
}
export async function requireChatGPTUser(
returnTo: string,
): Promise<ChatGPTUser> {
const user = await getChatGPTUser();
if (user) return user;
redirect(chatGPTSignInPath(returnTo));
}
export function chatGPTSignInPath(returnTo: string): string {
const safeReturnTo = safeRelativeReturnPath(returnTo);
return `${SIGN_IN_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`;
}
export function chatGPTSignOutPath(returnTo = "/"): string {
const safeReturnTo = safeRelativeReturnPath(returnTo);
return `${SIGN_OUT_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`;
}
function safeRelativeReturnPath(value: string): string {
if (!value.startsWith("/") || value.startsWith("//")) return "/";
let url: URL;
try {
url = new URL(value, "https://app.local");
} catch {
return "/";
}
if (url.origin !== "https://app.local") return "/";
if (isReservedAuthPath(url.pathname)) return "/";
return `${url.pathname}${url.search}${url.hash}`;
}
function isReservedAuthPath(pathname: string): boolean {
return (
pathname === SIGN_IN_PATH ||
pathname === SIGN_OUT_PATH ||
pathname === CALLBACK_PATH
);
}
function safeDecodeURIComponent(value: string): string | null {
try {
return decodeURIComponent(value);
} catch {
return null;
}
}
-20
View File
@@ -1,20 +0,0 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import "./globals.css";
export async function generateMetadata(): Promise<Metadata> {
const requestHeaders = await headers();
const host = requestHeaders.get("x-forwarded-host") ?? requestHeaders.get("host") ?? "localhost";
const protocol = requestHeaders.get("x-forwarded-proto") ?? (host.startsWith("localhost") ? "http" : "https");
const image = new URL("/og.png", `${protocol}://${host}`).toString();
return {
title: "Thinkloom — ideas into writing",
description: "A local-first writing studio for exploring ideas, shaping drafts, and preserving your creative process.",
icons: { icon: "/icon.png", shortcut: "/icon.png", apple: "/icon.png" },
openGraph: { title: "Thinkloom — ideas into writing", description: "Explore, shape, and publish thoughtful writing without losing the thread of how it came together.", type: "website", images: [{ url: image, width: 1732, height: 909, alt: "Thinkloom — ideas into writing, without losing the thread." }] },
twitter: { card: "summary_large_image", images: [image] },
};
}
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { return <html lang="en"><body>{children}</body></html>; }
-2
View File
@@ -1,2 +0,0 @@
import Thinkloom from "./thinkloom";
export default function Home() { return <Thinkloom />; }
-45
View File
@@ -1,45 +0,0 @@
import { access, cp, mkdir, rm } from "node:fs/promises";
import { resolve } from "node:path";
import type { Plugin } from "vite";
async function exists(path: string): Promise<boolean> {
try {
await access(path);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return false;
}
throw error;
}
}
// Packages Sites metadata and migrations after Vite finishes compiling.
export function sites(): Plugin {
let root = process.cwd();
return {
name: "sites",
apply: "build",
configResolved(config) {
root = config.root;
},
async closeBundle() {
const outputDirectory = resolve(root, "dist", ".openai");
const hostingConfig = resolve(root, ".openai", "hosting.json");
const drizzleSource = resolve(root, "drizzle");
await rm(outputDirectory, { recursive: true, force: true });
await mkdir(outputDirectory, { recursive: true });
if (await exists(hostingConfig)) {
await cp(hostingConfig, resolve(outputDirectory, "hosting.json"));
}
if (await exists(drizzleSource)) {
await cp(drizzleSource, resolve(outputDirectory, "drizzle"), {
recursive: true,
});
}
},
};
}
-7
View File
@@ -1,7 +0,0 @@
import { drizzle } from "drizzle-orm/d1";
import * as schema from "./schema";
/** Create a typed database client from the D1 binding supplied by the worker. */
export function getDb(binding: D1Database) {
return drizzle(binding, { schema });
}
-4
View File
@@ -1,4 +0,0 @@
// Intentionally empty by default.
// Add Drizzle tables here when the site actually needs a database.
// See examples/d1/db/schema.ts for an opt-in example.
export {};
-12
View File
@@ -1,12 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#f4f1e9" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self' ipc: http://127.0.0.1:* https://api.openai.com; media-src 'self' blob:; object-src 'none'; base-uri 'self'" />
<title>Thinkloom</title>
</head>
<body><div id="root"></div><script type="module" src="/main.tsx"></script></body>
</html>
-6
View File
@@ -1,6 +0,0 @@
import React from "react";
import { createRoot } from "react-dom/client";
import Thinkloom from "../app/thinkloom";
import "../app/globals.css";
createRoot(document.getElementById("root")!).render(<React.StrictMode><Thinkloom /></React.StrictMode>);
-7
View File
@@ -1,7 +0,0 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
out: "./drizzle",
schema: "./db/schema.ts",
dialect: "sqlite",
});
-5
View File
@@ -1,5 +0,0 @@
{
"version": "7",
"dialect": "sqlite",
"entries": []
}
+21 -17
View File
@@ -1,18 +1,22 @@
import { defineConfig, globalIgnores } from "eslint/config"; import js from "@eslint/js";
import nextVitals from "eslint-config-next/core-web-vitals"; import globals from "globals";
import nextTs from "eslint-config-next/typescript"; import reactHooks from "eslint-plugin-react-hooks";
import tseslint from "typescript-eslint";
const eslintConfig = defineConfig([ export default tseslint.config(
...nextVitals, { ignores: ["dist/**", "src-tauri/target/**", "node_modules/**"] },
...nextTs, js.configs.recommended,
// Override default ignores of eslint-config-next. ...tseslint.configs.recommended,
globalIgnores([ {
// Default ignores of eslint-config-next: files: ["src/**/*.{ts,tsx}", "vite.config.ts"],
".next/**", languageOptions: {
"out/**", globals: { ...globals.browser, ...globals.node },
"build/**", },
"next-env.d.ts", plugins: { "react-hooks": reactHooks },
]), rules: { ...reactHooks.configs.recommended.rules },
]); },
{
export default eslintConfig; files: ["tests/**/*.mjs"],
languageOptions: { globals: globals.node },
},
);
-58
View File
@@ -1,58 +0,0 @@
import { desc } from "drizzle-orm";
import { getDb } from "../../../../../db";
import { notes } from "../../../db/schema";
function toRouteErrorMessage(error: unknown) {
const message = error instanceof Error ? error.message : "Unexpected error";
const detail =
error instanceof Error && error.cause instanceof Error ? error.cause.message : "";
const combined = `${message}\n${detail}`;
if (combined.includes("no such table") || combined.includes('from "notes"')) {
return "The notes table is unavailable. Generate the migration locally with `npm run db:generate`, then deploy so the platform can apply the generated SQL to the real D1 database.";
}
return message;
}
export async function GET() {
try {
const db = getDb();
const rows = await db
.select()
.from(notes)
.orderBy(desc(notes.createdAt), desc(notes.id))
.limit(20);
return Response.json({ notes: rows });
} catch (error) {
return Response.json(
{ error: toRouteErrorMessage(error) },
{ status: 500 }
);
}
}
export async function POST(request: Request) {
try {
const payload = (await request.json()) as {
title?: string;
content?: string;
};
const title = payload.title?.trim() ?? "";
const content = payload.content?.trim() ?? "";
if (!title) {
return Response.json({ error: "title is required" }, { status: 400 });
}
const db = getDb();
const [note] = await db.insert(notes).values({ title, content }).returning();
return Response.json({ note }, { status: 201 });
} catch (error) {
return Response.json(
{ error: toRouteErrorMessage(error) },
{ status: 500 }
);
}
}
-9
View File
@@ -1,9 +0,0 @@
import { sql } from "drizzle-orm";
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
export const notes = sqliteTable("notes", {
id: integer("id").primaryKey({ autoIncrement: true }),
title: text("title").notNull(),
content: text("content").notNull().default(""),
createdAt: text("created_at").notNull().default(sql`CURRENT_TIMESTAMP`),
});
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#f4f1e9" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self' ipc: http://ipc.localhost http://127.0.0.1:* https://api.openai.com; media-src 'self' blob:; object-src 'none'; base-uri 'self'" />
<link rel="icon" href="/icon.png" />
<title>Thinkloom — ideas into writing</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
-7
View File
@@ -1,7 +0,0 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
+14 -8306
View File
File diff suppressed because it is too large Load Diff
+13 -26
View File
@@ -2,19 +2,15 @@
"name": "thinkloom", "name": "thinkloom",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"engines": { "type": "module",
"node": ">=22.13.0" "engines": { "node": ">=22.13.0" },
},
"scripts": { "scripts": {
"dev": "vinext dev", "dev": "vite --port 1420 --strictPort",
"build": "vinext build", "build": "npm run typecheck && vite build",
"start": "vinext start", "preview": "vite preview",
"test": "npm run typecheck && node --test tests/*.test.mjs",
"lint": "eslint . --ignore-pattern dist --ignore-pattern .next --ignore-pattern desktop-dist --ignore-pattern src-tauri/target",
"db:generate": "drizzle-kit generate",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"desktop:dev": "vite --config vite.desktop.config.ts", "lint": "eslint src tests vite.config.ts",
"desktop:build": "vite build --config vite.desktop.config.ts", "test": "npm run typecheck && node tests/native-app.test.mjs",
"tauri": "tauri" "tauri": "tauri"
}, },
"dependencies": { "dependencies": {
@@ -22,30 +18,21 @@
"@tiptap/pm": "^3.28.0", "@tiptap/pm": "^3.28.0",
"@tiptap/react": "^3.28.0", "@tiptap/react": "^3.28.0",
"@tiptap/starter-kit": "^3.28.0", "@tiptap/starter-kit": "^3.28.0",
"drizzle-orm": "0.45.2",
"next": "16.2.6",
"react": "19.2.6", "react": "19.2.6",
"react-dom": "19.2.6" "react-dom": "19.2.6"
}, },
"devDependencies": { "devDependencies": {
"@cloudflare/vite-plugin": "1.37.1", "@eslint/js": "9.39.4",
"@cloudflare/workers-types": "^4.20260702.1",
"@tailwindcss/postcss": "4.2.1",
"@tauri-apps/cli": "^2.11.4", "@tauri-apps/cli": "^2.11.4",
"@types/node": "22.19.19", "@types/node": "22.19.19",
"@types/react": "19.2.14", "@types/react": "19.2.14",
"@types/react-dom": "19.2.3", "@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "6.0.2", "@vitejs/plugin-react": "6.0.2",
"@vitejs/plugin-rsc": "0.5.26",
"drizzle-kit": "0.31.10",
"eslint": "9.39.4", "eslint": "9.39.4",
"eslint-config-next": "16.2.6", "eslint-plugin-react-hooks": "7.1.1",
"react-server-dom-webpack": "19.2.6", "globals": "14.0.0",
"tailwindcss": "4.2.1",
"typescript": "5.9.3", "typescript": "5.9.3",
"vinext": "0.0.50", "typescript-eslint": "8.59.3",
"vite": "8.0.13", "vite": "8.0.13"
"wrangler": "4.92.0" }
},
"type": "module"
} }
-7
View File
@@ -1,7 +0,0 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
-6
View File
@@ -1,6 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 19.2727C22 20.779 20.779 22 19.2727 22H14.7273C13.221 22 12 20.779 12 19.2727V12H19.2727C20.779 12 22 13.221 22 14.7273V19.2727Z" fill="#68C4FF"/>
<path d="M20 2C21.1046 2 22 2.89543 22 4V7C22 8.10457 21.1046 9 20 9H17C15.8954 9 15 8.10457 15 7V4C15 2.89543 15.8954 2 17 2H20Z" fill="#0C79D8"/>
<path d="M7 15C8.10457 15 9 15.8954 9 17V20C9 21.1046 8.10457 22 7 22H4C2.89543 22 2 21.1046 2 20V17C2 15.8954 2.89543 15 4 15H7Z" fill="#0C79D8"/>
<path d="M12 12H4.72727C3.22104 12 2 10.779 2 9.27273V4.72727C2 3.22104 3.22104 2 4.72727 2H9.27273C10.779 2 12 3.22104 12 4.72727V12Z" fill="#2E9EFF"/>
</svg>

Before

Width:  |  Height:  |  Size: 718 B

-1
View File
@@ -1 +0,0 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 391 B

-1
View File
@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

-1
View File
@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

Before

Width:  |  Height:  |  Size: 385 B

File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -4,10 +4,10 @@
"version": "0.1.0", "version": "0.1.0",
"identifier": "com.thinkloom.desktop", "identifier": "com.thinkloom.desktop",
"build": { "build": {
"beforeDevCommand": "npm run desktop:dev", "beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420", "devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm run desktop:build", "beforeBuildCommand": "npm run build",
"frontendDist": "../desktop-dist" "frontendDist": "../dist"
}, },
"app": { "app": {
"windows": [ "windows": [
+1 -6
View File
@@ -73,7 +73,7 @@ const StructuredEditor = forwardRef<StructuredEditorHandle, { value: string; onC
}); });
export default function Thinkloom() { export default function Thinkloom() {
const [project, setProject] = useState<State>(initial); const [hydrated, setHydrated] = useState(false); const [view, setView] = useState<View>("ideation"); const [message, setMessage] = useState(""); const [notice, setNotice] = useState("Project ready. History is being recorded."); const [busy, setBusy] = useState(false); const [listening, setListening] = useState(false); const [showRejected, setShowRejected] = useState(false); const [editingIdea, setEditingIdea] = useState<string | null>(null); const [newHabit, setNewHabit] = useState(""); const [sanitized, setSanitized] = useState(true); const [credential, setCredential] = useState(""); const [projectPath, setProjectPath] = useState(""); const editor = useRef<StructuredEditorHandle>(null); const finalEditor = useRef<HTMLTextAreaElement>(null); const conversationEnd = useRef<HTMLDivElement>(null); const [project, setProject] = useState<State>(initial); const [hydrated, setHydrated] = useState(false); const [view, setView] = useState<View>("ideation"); const [message, setMessage] = useState(""); const [notice, setNotice] = useState("Project ready. History is being recorded."); const [busy, setBusy] = useState(false); const [listening, setListening] = useState(false); const [showRejected, setShowRejected] = useState(false); const [editingIdea, setEditingIdea] = useState<string | null>(null); const [newHabit, setNewHabit] = useState(""); const [sanitized, setSanitized] = useState(true); const [credential, setCredential] = useState(""); const [projectPath, setProjectPath] = useState(""); const editor = useRef<StructuredEditorHandle>(null); const finalEditor = useRef<HTMLTextAreaElement>(null); const conversationEnd = useRef<HTMLDivElement>(null);
useEffect(() => { queueMicrotask(() => { try { const raw = localStorage.getItem("thinkloom-project-v1"); if (raw) setProject({ ...initial, ...JSON.parse(raw) as State }); } catch {} setHydrated(true); }); }, []); useEffect(() => { queueMicrotask(() => { try { const raw = localStorage.getItem("thinkloom-project-v1"); if (raw) setProject({ ...initial, ...JSON.parse(raw) as State }); } catch { /* Ignore invalid local recovery state. */ } setHydrated(true); }); }, []);
useEffect(() => { if (hydrated) localStorage.setItem("thinkloom-project-v1", JSON.stringify(project)); }, [project, hydrated]); useEffect(() => { if (hydrated) localStorage.setItem("thinkloom-project-v1", JSON.stringify(project)); }, [project, hydrated]);
const mutate = useCallback((type: string, summary: string, update: (current: State) => State, actor: Actor = "user") => { setProject((current) => { const previousHash = current.events.at(-1)?.hash ?? null; const event: Event = { id: uid("event"), type, actor, at: now(), summary, previousHash, hash: hash(`${previousHash}|${type}|${summary}|${Date.now()}`), provider: actor === "assistant" ? current.provider.name : undefined }; const next = { ...update(current), events: [...current.events, event], updatedAt: event.at }; void invokeNative("persist_state", { appState: next }).catch(() => undefined); return next; }); setNotice(summary); }, []); const mutate = useCallback((type: string, summary: string, update: (current: State) => State, actor: Actor = "user") => { setProject((current) => { const previousHash = current.events.at(-1)?.hash ?? null; const event: Event = { id: uid("event"), type, actor, at: now(), summary, previousHash, hash: hash(`${previousHash}|${type}|${summary}|${Date.now()}`), provider: actor === "assistant" ? current.provider.name : undefined }; const next = { ...update(current), events: [...current.events, event], updatedAt: event.at }; void invokeNative("persist_state", { appState: next }).catch(() => undefined); return next; }); setNotice(summary); }, []);
const navigate = (next: View) => { setView(next); if (["ideation", "drafting", "finalization"].includes(next) && project.phase !== next) mutate("PHASE_CHANGED", `Moved to ${next}`, (current) => ({ ...current, phase: next as Phase })); }; const navigate = (next: View) => { setView(next); if (["ideation", "drafting", "finalization"].includes(next) && project.phase !== next) mutate("PHASE_CHANGED", `Moved to ${next}`, (current) => ({ ...current, phase: next as Phase })); };
@@ -162,8 +162,3 @@ export default function Thinkloom() {
<footer className="app-footer"><span>Thinkloom 0.1.0</span><span>Local-first · audio retention always off</span><span>{project.events.at(-1)?.hash ?? "No history"}</span></footer> <footer className="app-footer"><span>Thinkloom 0.1.0</span><span>Local-first · audio retention always off</span><span>{project.events.at(-1)?.hash ?? "No history"}</span></footer>
</main>; </main>;
} }
View File
+8
View File
@@ -0,0 +1,8 @@
import React from "react";
import { createRoot } from "react-dom/client";
import Thinkloom from "./Thinkloom";
import "./globals.css";
createRoot(document.getElementById("root")!).render(
<React.StrictMode><Thinkloom /></React.StrictMode>,
);
+53
View File
@@ -0,0 +1,53 @@
import assert from "node:assert/strict";
import { readFile, readdir } from "node:fs/promises";
import test from "node:test";
const root = new URL("../", import.meta.url);
test("defines a native-only Thinkloom application shell", async () => {
const [html, packageJson, tauriConfig] = await Promise.all([
readFile(new URL("../index.html", import.meta.url), "utf8"),
readFile(new URL("../package.json", import.meta.url), "utf8"),
readFile(new URL("../src-tauri/tauri.conf.json", import.meta.url), "utf8"),
]);
assert.match(html, /Thinkloom — ideas into writing/i);
assert.match(html, /\/src\/main\.tsx/);
assert.doesNotMatch(`${html}\n${packageJson}`, /sites|next|vinext|wrangler|cloudflare/i);
assert.match(tauriConfig, /"beforeDevCommand": "npm run dev"/);
assert.match(tauriConfig, /"beforeBuildCommand": "npm run build"/);
assert.match(tauriConfig, /"frontendDist": "\.\.\/dist"/);
});
test("implements the control and privacy contracts", async () => {
const [source, css] = await Promise.all([
readFile(new URL("../src/Thinkloom.tsx", import.meta.url), "utf8"),
readFile(new URL("../src/globals.css", import.meta.url), "utf8"),
]);
for (const phrase of ["Insert at cursor", "Replace selection", "New section", "Discard", "History recorded", "No audio retained", "Approve for this project", "Relationships, not percentages"]) {
assert.match(source, new RegExp(phrase, "i"));
}
assert.match(source, /GENERATION_PARTIALLY_ACCEPTED/);
assert.match(source, /CLOUD_PROCESSING_APPROVED/);
assert.match(source, /store_provider_secret/);
assert.match(css, /prefers-reduced-motion/);
assert.match(css, /:focus-visible/);
});
test("never ships retained audio assets", async () => {
async function walk(url) {
const entries = await readdir(url, { withFileTypes: true });
const files = [];
for (const entry of entries) {
if (["node_modules", "dist", "target", ".git"].includes(entry.name)) continue;
const child = new URL(`${entry.name}${entry.isDirectory() ? "/" : ""}`, url);
if (entry.isDirectory()) files.push(...await walk(child));
else files.push(entry.name);
}
return files;
}
const files = await walk(root);
assert.equal(files.some((name) => /\.(wav|mp3|m4a|ogg|webm)$/i.test(name)), false);
});
-43
View File
@@ -1,43 +0,0 @@
import assert from "node:assert/strict";
import { readFile, readdir } from "node:fs/promises";
import test from "node:test";
const root = new URL("../", import.meta.url);
async function render() {
const workerUrl = new URL("../dist/server/index.js", import.meta.url);
workerUrl.searchParams.set("test", `${process.pid}-${Date.now()}`);
const { default: worker } = await import(workerUrl.href);
return worker.fetch(new Request("http://localhost/", { headers: { accept: "text/html" } }), { ASSETS: { fetch: async () => new Response("Not found", { status: 404 }) } }, { waitUntil() {}, passThroughOnException() {} });
}
test("serves the Thinkloom product shell", async () => {
const response = await render();
assert.equal(response.status, 200);
assert.match(response.headers.get("content-type") ?? "", /^text\/html\b/i);
const html = await response.text();
assert.match(html, /Thinkloom — ideas into writing/i);
assert.match(html, /Gathering your threads/i);
assert.doesNotMatch(html, /codex-preview|starter project|react-loading-skeleton/i);
});
test("implements the control and privacy contracts", async () => {
const [source, css, layout] = await Promise.all([
readFile(new URL("../app/thinkloom.tsx", import.meta.url), "utf8"),
readFile(new URL("../app/globals.css", import.meta.url), "utf8"),
readFile(new URL("../app/layout.tsx", import.meta.url), "utf8"),
]);
for (const phrase of ["Insert at cursor", "Replace selection", "New section", "Discard", "History recorded", "No audio retained", "Approve for this project", "Relationships, not percentages"]) assert.match(source, new RegExp(phrase, "i"));
assert.match(source, /GENERATION_PARTIALLY_ACCEPTED/);
assert.match(source, /CLOUD_PROCESSING_APPROVED/);
assert.match(source, /store_provider_secret/);
assert.match(css, /prefers-reduced-motion/);
assert.match(css, /:focus-visible/);
assert.match(layout, /Thinkloom — ideas into writing/);
});
test("never ships retained audio assets", async () => {
async function walk(url) { const entries = await readdir(url, { withFileTypes: true }); const files = []; for (const entry of entries) { if (["node_modules", "dist", "desktop-dist", "target", ".git"].includes(entry.name)) continue; const child = new URL(`${entry.name}${entry.isDirectory() ? "/" : ""}`, url); if (entry.isDirectory()) files.push(...await walk(child)); else files.push(entry.name); } return files; }
const files = await walk(root);
assert.equal(files.some((name) => /\.(wav|mp3|m4a|ogg|webm)$/i.test(name)), false);
});
+9 -27
View File
@@ -1,38 +1,20 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2017", "target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"], "useDefineForClassFields": true,
"allowJs": true, "lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["@cloudflare/workers-types"], "allowJs": false,
"skipLibCheck": true, "skipLibCheck": true,
"strict": true, "strict": true,
"noEmit": true, "noEmit": true,
"esModuleInterop": true, "esModuleInterop": true,
"module": "esnext", "module": "ESNext",
"moduleResolution": "bundler", "moduleResolution": "Bundler",
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"jsx": "react-jsx", "jsx": "react-jsx",
"incremental": true, "types": ["vite/client", "node"]
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
}, },
"include": [ "include": ["src", "vite.config.ts"],
"next-env.d.ts", "exclude": ["node_modules", "dist", "src-tauri/target"]
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules", "examples"]
} }
+6 -56
View File
@@ -1,59 +1,9 @@
import vinext from "vinext";
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import hostingConfig from "./.openai/hosting.json"; import react from "@vitejs/plugin-react";
import { sites } from "./build/sites-vite-plugin";
const SITE_CREATOR_PLACEHOLDER_DATABASE_ID = export default defineConfig({
"00000000-0000-4000-8000-000000000000"; plugins: [react()],
server: { port: 1420, strictPort: true },
const { d1, r2 } = hostingConfig; clearScreen: false,
build: { outDir: "dist", emptyOutDir: true },
// macOS Seatbelt blocks FSEvents, so Codex previews need polling for HMR.
const isCodexSeatbeltSandbox = process.env.CODEX_SANDBOX === "seatbelt";
const localBindingConfig = {
main: "./worker/index.ts",
compatibility_flags: ["nodejs_compat"],
d1_databases: d1
? [
{
binding: d1,
database_name: "site-creator-d1",
database_id: SITE_CREATOR_PLACEHOLDER_DATABASE_ID,
},
]
: [],
r2_buckets: r2
? [
{
binding: r2,
bucket_name: "site-creator-r2",
},
]
: [],
};
export default defineConfig(async () => {
// Keep Wrangler and Miniflare state project-local. These are non-secret tool
// settings; application environment belongs in ignored `.env*` files.
process.env.WRANGLER_WRITE_LOGS ??= "false";
process.env.WRANGLER_LOG_PATH ??= ".wrangler/logs";
process.env.MINIFLARE_REGISTRY_PATH ??= ".wrangler/registry";
// Wrangler snapshots its log path while the Cloudflare plugin is imported.
const { cloudflare } = await import("@cloudflare/vite-plugin");
return {
server: isCodexSeatbeltSandbox
? { watch: { useFsEvents: false, usePolling: true } }
: undefined,
plugins: [
vinext(),
sites(),
cloudflare({
viteEnvironment: { name: "rsc", childEnvironments: ["ssr"] },
config: localBindingConfig,
}),
],
};
}); });
-10
View File
@@ -1,10 +0,0 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
root: "desktop",
build: { outDir: "../desktop-dist", emptyOutDir: true },
server: { port: 1420, strictPort: true },
clearScreen: false,
});
-47
View File
@@ -1,47 +0,0 @@
/** Cloudflare Worker entry point for the vinext-starter template. */
import { handleImageOptimization, DEFAULT_DEVICE_SIZES, DEFAULT_IMAGE_SIZES } from "vinext/server/image-optimization";
import handler from "vinext/server/app-router-entry";
interface Env {
ASSETS: Fetcher;
DB: D1Database;
IMAGES: {
input(stream: ReadableStream): {
transform(options: Record<string, unknown>): {
output(options: { format: string; quality: number }): Promise<{ response(): Response }>;
};
};
};
}
interface ExecutionContext {
waitUntil(promise: Promise<unknown>): void;
passThroughOnException(): void;
}
// Image security config. SVG sources with .svg extension auto-skip the
// optimization endpoint on the client side (served directly, no proxy).
// To route SVGs through the optimizer (with security headers), set
// dangerouslyAllowSVG: true in next.config.js and uncomment below:
// const imageConfig: ImageConfig = { dangerouslyAllowSVG: true };
const worker = {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === "/_vinext/image") {
const allowedWidths = [...DEFAULT_DEVICE_SIZES, ...DEFAULT_IMAGE_SIZES];
return handleImageOptimization(request, {
fetchAsset: (path) => env.ASSETS.fetch(new Request(new URL(path, request.url))),
transformImage: async (body, { width, format, quality }) => {
const result = await env.IMAGES.input(body).transform(width > 0 ? { width } : {}).output({ format, quality });
return result.response();
},
}, allowedWidths);
}
return handler.fetch(request, env, ctx);
},
};
export default worker;