From 6bc681df5f63ded1d54e2a7d2f22cd31bfbe1dd8 Mon Sep 17 00:00:00 2001 From: Schwitzz Date: Fri, 30 Oct 2026 13:01:40 -0500 Subject: [PATCH] feat: advance and highlight next AI suggestion on accept/deny/copy-to-note, remove copied suggestion from inline list, and add native Linux .deb packaging --- .gitignore | 1 + apps/web/src/features/ai/AISidebar.tsx | 46 +++- apps/web/src/features/ai/ai-accept.test.tsx | 24 ++ desktop/build-deb.sh | 134 ++++++++++ desktop/src/pubscript-desktop.c | 270 ++++++++++++++++++++ package.json | 3 +- server/src/index.ts | 25 ++ 7 files changed, 497 insertions(+), 6 deletions(-) create mode 100755 desktop/build-deb.sh create mode 100644 desktop/src/pubscript-desktop.c diff --git a/.gitignore b/.gitignore index 19e90bc..7ab69f7 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ data/ # Tauri / desktop desktop/target/ +*.deb # Logs *.log diff --git a/apps/web/src/features/ai/AISidebar.tsx b/apps/web/src/features/ai/AISidebar.tsx index 428e253..f395e38 100644 --- a/apps/web/src/features/ai/AISidebar.tsx +++ b/apps/web/src/features/ai/AISidebar.tsx @@ -910,6 +910,32 @@ export function AISidebar({ fullPage = false }: AISidebarProps) { setFocusedIndex(0); }; + const advanceAndHighlightNext = ( + removedId: string, + updatedAll: AISuggestion[], + ) => { + const prevIdx = pendingSuggestions.findIndex((item) => item.id === removedId); + const nextPending = updatedAll.filter((item) => item.status === "pending"); + if (nextPending.length === 0) { + setFocusedIndex(0); + setHighlightedLine(null); + setHighlightedText(null); + return; + } + const nextIdx = Math.min( + prevIdx >= 0 ? prevIdx : focusedIndex, + nextPending.length - 1, + ); + const nextSg = nextPending[nextIdx]!; + setFocusedIndex(nextIdx); + if (nextSg.originalText) { + setHighlightedText(nextSg.originalText); + } + if (nextSg.lineNumber != null) { + setHighlightedLine(nextSg.lineNumber); + } + }; + const handleAcceptSuggestion = async (sg: AISuggestion) => { if (!aiStore || !activeSessionId) return; const replacement = (draftEdits[sg.id] ?? sg.newText ?? sg.text).trim(); @@ -950,6 +976,7 @@ export function AISidebar({ fullPage = false }: AISidebarProps) { const nextList = await aiStore.listSuggestions(activeSessionId); setSuggestions(nextList); + advanceAndHighlightNext(sg.id, nextList); if (applied) { toast.success(`Accepted granular edit: "${replacement.slice(0, 36)}${replacement.length > 36 ? "…" : ""}"`); @@ -968,10 +995,7 @@ export function AISidebar({ fullPage = false }: AISidebarProps) { } const nextList = await aiStore.listSuggestions(activeSessionId); setSuggestions(nextList); - if (highlightedText === sg.originalText || highlightedLine === sg.lineNumber) { - setHighlightedLine(null); - setHighlightedText(null); - } + advanceAndHighlightNext(sg.id, nextList); toast.info(`Denied suggestion: ${sg.title}`); }; @@ -995,7 +1019,19 @@ export function AISidebar({ fullPage = false }: AISidebarProps) { status: "draft", }); setNotes(await aiStore.listNotes(activeSessionId)); - toast.success(`Copied "${sg.title}" to Notes`); + + // Remove the suggestion from the inline edits list and advance to the next suggestion + setSuggestions((prev) => prev.filter((item) => item.id !== sg.id)); + if (typeof aiStore.deleteSuggestion === "function") { + await aiStore.deleteSuggestion(activeSessionId, sg.id); + } else { + await aiStore.resolveSuggestion(activeSessionId, sg.id, false); + } + const nextList = await aiStore.listSuggestions(activeSessionId); + setSuggestions(nextList); + advanceAndHighlightNext(sg.id, nextList); + + toast.success(`Moved "${sg.title}" to Notes`); }; const handleAddNote = async () => { diff --git a/apps/web/src/features/ai/ai-accept.test.tsx b/apps/web/src/features/ai/ai-accept.test.tsx index 1d0e09f..faa2a02 100644 --- a/apps/web/src/features/ai/ai-accept.test.tsx +++ b/apps/web/src/features/ai/ai-accept.test.tsx @@ -139,6 +139,11 @@ describe("AISidebar + WritingView granular text-pegged suggestion flow", () => { await new Promise((r) => setTimeout(r, 120)); }); + // After accepting Suggestion #1, the next suggestion (#2) must be automatically highlighted + expect(useProjectStore.getState().highlightedText).toContain( + "the light had turned the sea the color of a bruised plum", + ); + // Accept Suggestion #2 (clause 2 of the SAME paragraph 2) const acceptBtn2 = Array.from(container.querySelectorAll("button")).find((b) => b.textContent?.includes("Accept"), @@ -150,6 +155,25 @@ describe("AISidebar + WritingView granular text-pegged suggestion flow", () => { await new Promise((r) => setTimeout(r, 120)); }); + // After accepting Suggestion #2, Suggestion #3 (on the next paragraph) must be automatically highlighted + expect(useProjectStore.getState().highlightedText).toContain( + "She pressed her palm against the cold window", + ); + expect(container.querySelector(".ps-editor")?.getAttribute("data-highlight-line")).toBe("3"); + + // Clicking "Copy to Note" on Suggestion #3 removes it from the inline suggestions list + const beforeCards = container.querySelectorAll(".ps-ai-suggestion-card").length; + const copyToNoteBtn = Array.from(container.querySelectorAll("button")).find((b) => + b.textContent?.includes("Copy to Note"), + ); + expect(copyToNoteBtn).toBeDefined(); + await act(async () => { + copyToNoteBtn!.click(); + await new Promise((r) => setTimeout(r, 120)); + }); + const afterCards = container.querySelectorAll(".ps-ai-suggestion-card").length; + expect(afterCards).toBe(beforeCards - 1); + // Verify BOTH granular clauses in paragraph 2 were updated AND the rest of the paragraph was NOT deleted! const pmText = container.querySelector(".ProseMirror")?.textContent ?? ""; expect(pmText).toContain("The ferry that carried Mara Vell to the Glass Harbor cleared the mainland before dawn"); diff --git a/desktop/build-deb.sh b/desktop/build-deb.sh new file mode 100755 index 0000000..daaa751 --- /dev/null +++ b/desktop/build-deb.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +PKG_NAME="pubscript" +PKG_VERSION="0.1.0" +ARCH="amd64" +DEB_NAME="${PKG_NAME}_${PKG_VERSION}_${ARCH}.deb" + +STAGE_ROOT="/tmp/opencode/pubscript-deb-stage" +STAGE_DIR="${STAGE_ROOT}/${PKG_NAME}_${PKG_VERSION}_${ARCH}" + +echo "==> [1/6] Building production web bundle (apps/web/dist)..." +pnpm --filter @pubscript/web run build + +echo "==> [2/6] Preparing Debian package staging tree at ${STAGE_DIR}..." +rm -rf "$STAGE_DIR" +mkdir -p \ + "$STAGE_DIR/DEBIAN" \ + "$STAGE_DIR/usr/bin" \ + "$STAGE_DIR/usr/share/applications" \ + "$STAGE_DIR/usr/share/icons/hicolor/scalable/apps" \ + "$STAGE_DIR/usr/share/pixmaps" \ + "$STAGE_DIR/opt/pubscript/apps/web" \ + "$STAGE_DIR/opt/pubscript/server/dist" + +echo "==> [3/6] Deploying production server dependencies and bundling server entry..." +pnpm --filter @pubscript/server --prod deploy --legacy "$STAGE_DIR/opt/pubscript/server-runtime" +cp -a "$STAGE_DIR/opt/pubscript/server-runtime/node_modules" "$STAGE_DIR/opt/pubscript/node_modules" +rm -rf "$STAGE_DIR/opt/pubscript/server-runtime" + +cat > "$STAGE_DIR/opt/pubscript/package.json" <<'EOF' +{ + "name": "pubscript-desktop-runtime", + "version": "0.1.0", + "private": true, + "type": "module" +} +EOF + +# Bundle server TypeScript + @pubscript/core into ESM while keeping native/CJS deps external +./apps/web/node_modules/.bin/esbuild server/src/index.ts \ + --bundle \ + --platform=node \ + --target=node22 \ + --format=esm \ + --outfile="$STAGE_DIR/opt/pubscript/server/dist/server.mjs" \ + --external:better-sqlite3 \ + --external:argon2 \ + --external:fastify \ + --external:@fastify/cors \ + --external:@fastify/cookie \ + --external:@fastify/static \ + --external:@fastify/websocket \ + --external:ws \ + --external:ulid \ + --external:zod + +cp -a apps/web/dist "$STAGE_DIR/opt/pubscript/apps/web/dist" + +echo "==> [4/6] Compiling native GTK3 + WebKit2GTK desktop binary (/usr/bin/pubscript)..." +gcc $(pkg-config --cflags gtk+-3.0) \ + -O2 -Wall -Wextra \ + desktop/src/pubscript-desktop.c \ + $(pkg-config --libs gtk+-3.0) -ldl \ + -o "$STAGE_DIR/usr/bin/pubscript" +chmod 0755 "$STAGE_DIR/usr/bin/pubscript" + +echo "==> [5/6] Installing icons, .desktop launcher, and DEBIAN metadata..." +cp apps/web/public/logo.svg "$STAGE_DIR/usr/share/icons/hicolor/scalable/apps/pubscript.svg" +cp apps/web/public/logo.svg "$STAGE_DIR/usr/share/pixmaps/pubscript.svg" + +cat > "$STAGE_DIR/usr/share/applications/pubscript.desktop" <<'EOF' +[Desktop Entry] +Version=1.0 +Type=Application +Name=PubScript +GenericName=Novel & Manuscript Writing Studio +Comment=Local-first writing studio for novelists with AI co-writing, plot boards, and manuscript export +Exec=/usr/bin/pubscript %U +Icon=pubscript +Terminal=false +Categories=Office;WordProcessor;TextEditor;Publishing; +Keywords=writing;novel;manuscript;markdown;epub;author;editor; +StartupNotify=true +StartupWMClass=pubscript +EOF + +INSTALLED_KB="$(du -sk "$STAGE_DIR/usr" "$STAGE_DIR/opt" | awk '{s+=$1} END {print s}')" + +cat > "$STAGE_DIR/DEBIAN/control" <= 2.34), libgtk-3-0 (>= 3.24), libwebkit2gtk-4.1-0 | libwebkit2gtk-4.0-37, nodejs (>= 18) +Maintainer: PubScript Studio +Homepage: https://pubscript.local +Description: PubScript — Local-First Novel & Manuscript Writing Studio + A multi-surface writing studio for novelists featuring an OnlyOffice/Word-grade + manuscript editor, granular text-anchored AI co-writing suggestions, autosave + revision history, character & world-building sheets, plot beat templates, + interactive whiteboard, and deterministic HTML/EPUB/ODT/DOCX/PDF export. +EOF + +cat > "$STAGE_DIR/DEBIAN/postinst" <<'EOF' +#!/bin/sh +set -e +if command -v update-desktop-database >/dev/null 2>&1; then + update-desktop-database -q /usr/share/applications || true +fi +if command -v gtk-update-icon-cache >/dev/null 2>&1; then + gtk-update-icon-cache -q -t -f /usr/share/icons/hicolor || true +fi +exit 0 +EOF +chmod 0755 "$STAGE_DIR/DEBIAN/postinst" + +echo "==> Verifying compiled desktop binary & embedded server via --headless-check..." +PUBSCRIPT_APP_ROOT="$STAGE_DIR/opt/pubscript" \ +PUBSCRIPT_DESKTOP_PORT=18099 \ + "$STAGE_DIR/usr/bin/pubscript" --headless-check + +echo "==> [6/6] Building .deb archive with dpkg-deb..." +mkdir -p "$ROOT_DIR/dist" +dpkg-deb --root-owner-group --build "$STAGE_DIR" "$ROOT_DIR/dist/${DEB_NAME}" +cp -f "$ROOT_DIR/dist/${DEB_NAME}" "$ROOT_DIR/${DEB_NAME}" + +echo "==> Built Debian package: $ROOT_DIR/dist/${DEB_NAME} (and $ROOT_DIR/${DEB_NAME})" +dpkg-deb --info "$ROOT_DIR/dist/${DEB_NAME}" diff --git a/desktop/src/pubscript-desktop.c b/desktop/src/pubscript-desktop.c new file mode 100644 index 0000000..41dc85e --- /dev/null +++ b/desktop/src/pubscript-desktop.c @@ -0,0 +1,270 @@ +/* + * PubScript Native Linux Desktop Shell (GTK3 + WebKit2GTK 4.1) + * + * Spawns the embedded local PubScript Fastify + SQLite server on port 18081 + * (storing user data in ~/.local/share/pubscript/pubscript.sqlite) and renders + * the full PubScript studio inside a native GTK3 + WebKit2GTK 4.1 window with + * persistent IndexedDB and localStorage in ~/.local/share/pubscript/webdata. + */ +#define _POSIX_C_SOURCE 200809L +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static pid_t g_server_pid = -1; + +static void stop_server(void) { + if (g_server_pid > 0) { + kill(g_server_pid, SIGTERM); + for (int i = 0; i < 20; i++) { + int status = 0; + pid_t r = waitpid(g_server_pid, &status, WNOHANG); + if (r == g_server_pid || r < 0) { + g_server_pid = -1; + return; + } + usleep(25000); + } + kill(g_server_pid, SIGKILL); + waitpid(g_server_pid, NULL, 0); + g_server_pid = -1; + } +} + +static void on_sigterm(int sig) { + (void)sig; + stop_server(); + _exit(0); +} + +static bool is_port_listening(int port) { + int sock = socket(AF_INET, SOCK_STREAM, 0); + if (sock < 0) return false; + struct sockaddr_in addr; + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_port = htons((uint16_t)port); + inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr); + bool ok = (connect(sock, (struct sockaddr *)&addr, sizeof(addr)) == 0); + close(sock); + return ok; +} + +static void mkdir_p(const char *path) { + char tmp[1024]; + snprintf(tmp, sizeof(tmp), "%s", path); + size_t len = strlen(tmp); + if (len == 0) return; + if (tmp[len - 1] == '/') tmp[len - 1] = '\0'; + for (char *p = tmp + 1; *p; p++) { + if (*p == '/') { + *p = '\0'; + mkdir(tmp, 0755); + *p = '/'; + } + } + mkdir(tmp, 0755); +} + +static void start_embedded_server(int port, const char *data_dir, const char *app_root) { + if (is_port_listening(port)) { + return; + } + + char db_path[1024]; + snprintf(db_path, sizeof(db_path), "%s/pubscript.sqlite", data_dir); + + char web_dist[1024]; + snprintf(web_dist, sizeof(web_dist), "%s/apps/web/dist", app_root); + + char server_entry[1024]; + snprintf(server_entry, sizeof(server_entry), "%s/server/dist/server.mjs", app_root); + + char port_str[32]; + snprintf(port_str, sizeof(port_str), "%d", port); + + char origin_str[128]; + snprintf(origin_str, sizeof(origin_str), "http://127.0.0.1:%d", port); + + pid_t pid = fork(); + if (pid == 0) { + setenv("PORT", port_str, 1); + setenv("PUBSCRIPT_DB", db_path, 1); + setenv("PUBSCRIPT_WEB_DIST", web_dist, 1); + setenv("PUBSCRIPT_ORIGIN", origin_str, 1); + if (chdir(app_root) != 0) { + /* ignore */ + } + int log_fd = open("/tmp/pubscript-desktop-server.log", O_CREAT | O_WRONLY | O_TRUNC, 0644); + if (log_fd >= 0) { + dup2(log_fd, STDOUT_FILENO); + dup2(log_fd, STDERR_FILENO); + close(log_fd); + } + execlp("node", "node", server_entry, (char *)NULL); + _exit(127); + } else if (pid > 0) { + g_server_pid = pid; + for (int i = 0; i < 60; i++) { + if (is_port_listening(port)) { + break; + } + usleep(50000); + } + } +} + +static void on_window_destroy(GtkWidget *widget, gpointer data) { + (void)widget; + (void)data; + stop_server(); + gtk_main_quit(); +} + +int main(int argc, char *argv[]) { + const char *home = getenv("HOME"); + if (!home || !*home) home = "/tmp"; + + char data_dir[1024]; + snprintf(data_dir, sizeof(data_dir), "%s/.local/share/pubscript", home); + mkdir_p(data_dir); + + char webkit_data_dir[1024]; + snprintf(webkit_data_dir, sizeof(webkit_data_dir), "%s/webdata", data_dir); + mkdir_p(webkit_data_dir); + + char webkit_cache_dir[1024]; + snprintf(webkit_cache_dir, sizeof(webkit_cache_dir), "%s/webcache", data_dir); + mkdir_p(webkit_cache_dir); + + const char *app_root = getenv("PUBSCRIPT_APP_ROOT"); + if (!app_root || !*app_root) { + app_root = "/opt/pubscript"; + } + + int port = 18081; + const char *env_port = getenv("PUBSCRIPT_DESKTOP_PORT"); + if (env_port && *env_port) { + int p = atoi(env_port); + if (p > 0 && p < 65536) port = p; + } + + signal(SIGINT, on_sigterm); + signal(SIGTERM, on_sigterm); + atexit(stop_server); + + start_embedded_server(port, data_dir, app_root); + + char url[256]; + snprintf(url, sizeof(url), "http://127.0.0.1:%d/", port); + + for (int i = 1; i < argc; i++) { + if (strcmp(argv[i], "--version") == 0) { + printf("PubScript Desktop 0.1.0\n"); + stop_server(); + return 0; + } + if (strcmp(argv[i], "--headless-check") == 0) { + bool up = is_port_listening(port); + printf("{\"ok\":%s,\"url\":\"%s\",\"dataDir\":\"%s\"}\n", + up ? "true" : "false", url, data_dir); + stop_server(); + return up ? 0 : 1; + } + } + + gtk_init(&argc, &argv); + + void *wk = dlopen("libwebkit2gtk-4.1.so.0", RTLD_LAZY | RTLD_GLOBAL); + if (!wk) { + wk = dlopen("libwebkit2gtk-4.0.so.37", RTLD_LAZY | RTLD_GLOBAL); + } + if (!wk) { + fprintf(stderr, "WebKit2GTK not found (%s); falling back to xdg-open %s\n", dlerror(), url); + char cmd[512]; + snprintf(cmd, sizeof(cmd), "xdg-open '%s' >/dev/null 2>&1 &", url); + int r = system(cmd); + (void)r; + return 0; + } + + typedef void *(*fn_data_mgr_new)(const char *, const char *, const char *, const char *, void *); + typedef void *(*fn_ctx_new_with_mgr)(void *); + typedef GtkWidget *(*fn_webview_new_with_ctx)(void *); + typedef GtkWidget *(*fn_webview_new)(void); + typedef void *(*fn_webview_get_settings)(GtkWidget *); + typedef void (*fn_webview_load_uri)(GtkWidget *, const char *); + + fn_data_mgr_new p_data_mgr_new = (fn_data_mgr_new)dlsym(wk, "webkit_website_data_manager_new"); + fn_ctx_new_with_mgr p_ctx_new_with_mgr = (fn_ctx_new_with_mgr)dlsym(wk, "webkit_web_context_new_with_website_data_manager"); + fn_webview_new_with_ctx p_webview_new_with_ctx = (fn_webview_new_with_ctx)dlsym(wk, "webkit_web_view_new_with_context"); + fn_webview_new p_webview_new = (fn_webview_new)dlsym(wk, "webkit_web_view_new"); + fn_webview_get_settings p_get_settings = (fn_webview_get_settings)dlsym(wk, "webkit_web_view_get_settings"); + fn_webview_load_uri p_load_uri = (fn_webview_load_uri)dlsym(wk, "webkit_web_view_load_uri"); + + GtkWidget *window = gtk_window_new(GTK_WINDOW_TOPLEVEL); + gtk_window_set_title(GTK_WINDOW(window), "PubScript — Writing Studio"); + gtk_window_set_default_size(GTK_WINDOW(window), 1440, 900); + gtk_window_set_position(GTK_WINDOW(window), GTK_WIN_POS_CENTER); + + const char *icon_path = "/usr/share/icons/hicolor/scalable/apps/pubscript.svg"; + if (access(icon_path, R_OK) == 0) { + gtk_window_set_icon_from_file(GTK_WINDOW(window), icon_path, NULL); + } + + GtkWidget *webview = NULL; + if (p_data_mgr_new && p_ctx_new_with_mgr && p_webview_new_with_ctx) { + void *mgr = p_data_mgr_new( + "base-data-directory", webkit_data_dir, + "base-cache-directory", webkit_cache_dir, + NULL + ); + void *ctx = p_ctx_new_with_mgr(mgr); + webview = p_webview_new_with_ctx(ctx); + } else if (p_webview_new) { + webview = p_webview_new(); + } + + if (!webview || !p_load_uri) { + fprintf(stderr, "Failed to initialize WebKitWebView\n"); + stop_server(); + return 1; + } + + if (p_get_settings) { + void *settings = p_get_settings(webview); + if (settings) { + g_object_set( + G_OBJECT(settings), + "enable-developer-extras", TRUE, + "enable-smooth-scrolling", TRUE, + "javascript-can-access-clipboard", TRUE, + "enable-webgl", TRUE, + NULL + ); + } + } + + gtk_container_add(GTK_CONTAINER(window), webview); + g_signal_connect(window, "destroy", G_CALLBACK(on_window_destroy), NULL); + + p_load_uri(webview, url); + gtk_widget_show_all(window); + gtk_main(); + + stop_server(); + return 0; +} diff --git a/package.json b/package.json index 6f39c57..a9450cb 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "test": "pnpm -r run test", "lint": "pnpm -r run lint", "typecheck": "pnpm -r run typecheck", - "contract": "pnpm --filter @pubscript/core run contract" + "contract": "pnpm --filter @pubscript/core run contract", + "package:deb": "bash desktop/build-deb.sh" }, "devDependencies": { "@eslint/js": "^9.17.0", diff --git a/server/src/index.ts b/server/src/index.ts index b55b9b2..509e47d 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,7 +1,10 @@ +import { existsSync } from "node:fs"; +import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import Fastify from "fastify"; import cors from "@fastify/cors"; import cookie from "@fastify/cookie"; +import fastifyStatic from "@fastify/static"; import websocket from "@fastify/websocket"; import { ZodError } from "zod"; import { config } from "./config.js"; @@ -68,6 +71,28 @@ export async function buildApp() { registerAdminRoutes(app); registerWs(app); + // Serve the built web app when apps/web/dist (or PUBSCRIPT_WEB_DIST) is present + const here = dirname(fileURLToPath(import.meta.url)); + const webRoot = + process.env.PUBSCRIPT_WEB_DIST || resolve(here, "../../apps/web/dist"); + if (existsSync(webRoot) && existsSync(resolve(webRoot, "index.html"))) { + await app.register(fastifyStatic, { + root: webRoot, + prefix: "/", + wildcard: false, + }); + app.setNotFoundHandler((req, reply) => { + if ( + req.method === "GET" && + !req.url.startsWith("/api/") && + !req.url.startsWith("/ws") + ) { + return reply.sendFile("index.html"); + } + return reply.code(404).send({ error: "not_found" }); + }); + } + return app; }