-
-
+
All Threads
@@ -130,15 +128,7 @@ export function SessionsSidebar() {
<>
No projects yet
- }
+ icon={}
onClick={addProject}
>
Add project
@@ -167,7 +157,7 @@ export function SessionsSidebar() {
-
+
Settings
diff --git a/apps/web/src/projects/page.tsx b/apps/web/src/projects/page.tsx
index 6e317b15..a9c1cd4b 100644
--- a/apps/web/src/projects/page.tsx
+++ b/apps/web/src/projects/page.tsx
@@ -1,5 +1,4 @@
-import { FolderTree } from "lucide-react";
-
+import { FolderTree } from "@/components/vuesax";
import { Page } from "../shell/page";
import { Projects } from "./projects";
diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx
index 7cef6381..ac559ede 100644
--- a/apps/web/src/routes/__root.tsx
+++ b/apps/web/src/routes/__root.tsx
@@ -2,6 +2,7 @@ import { createRootRoute, Outlet, useLocation } from "@tanstack/react-router";
import type { CSSProperties } from "react";
import { SidebarProvider } from "@/components/ui/sidebar";
+import { useScrollbars } from "@/hooks/use-scrollbars";
import { SessionsSidebar } from "../landing/sidebar";
import { SettingsSidebar } from "../settings/layout";
import { NavigationControls } from "../shell/titlebar";
@@ -11,6 +12,7 @@ import { Splash } from "../splash";
// The sessions sidebar wraps every page but settings, which has its own; its open state carries over.
function Root() {
+ useScrollbars();
const inSettings = useLocation({ select: (location) => location.pathname.startsWith("/settings") });
return (
section.group))];
return (
-
Settings
+
+
+
+
+
Settings
+
} title="Local" detail="Runs on this machine" />
}
+ icon={}
title="Remote"
detail="Another machine's server"
badge={Coming soon}
diff --git a/apps/web/src/settings/servers/local.tsx b/apps/web/src/settings/servers/local.tsx
index 55d21072..0f15d604 100644
--- a/apps/web/src/settings/servers/local.tsx
+++ b/apps/web/src/settings/servers/local.tsx
@@ -1,6 +1,7 @@
+import { Check, Copy, Trash2, TriangleAlert, Undo2 } from "@/components/vuesax";
import { useAtomSet } from "@effect/atom-react";
import { type CliAction, LOCAL_SERVER, type LocalServerStatus, type ServerUpdate } from "@codeworksh/contract";
-import { Check, Copy, Trash2, TriangleAlert, Undo2 } from "lucide-react";
+
import { type ReactNode, useState } from "react";
import {
@@ -18,7 +19,7 @@ import { Button } from "@/components/ui/button";
import { messageOf } from "@/lib/utils";
import brand from "../../assets/codework.png";
import { Server } from "../../kernel/rpc";
-import { Code, quietField as field, SavingInput } from "../blocks";
+import { Code, Group as Card, quietField as field, SavingInput } from "../blocks";
import { Variables } from "./variables";
const serviceAtom = Server.mutation("servers.service");
@@ -46,7 +47,7 @@ export const Badge = ({ tone, children }: { tone: Tone; children: ReactNode }) =
export const Mark = () => ;
const Version = ({ version }: { version: string }) => (
- v{version}
+ v{version}
);
/**
@@ -79,11 +80,11 @@ export function LocalItem({ status }: { status: LocalServerStatus }) {
// Only the binary is judged here; the server's own state belongs to the detail.
const problem = cli === null ? "No codework found" : "failure" in cli ? cli.failure : undefined;
return (
-
+
-
+
{status.name}
{cli !== null && "version" in cli && }
@@ -92,12 +93,9 @@ export function LocalItem({ status }: { status: LocalServerStatus }) {
{problem}
)}
-
-
-
- Local
{status.isDefault && Default}
+
);
@@ -158,11 +156,11 @@ export function LocalDetail({ status }: { status: LocalServerStatus }) {
};
return (
-
+
{/* As tall as its buttons, so a server that can be removed does not sit lower than one that cannot. */}
-
+
- {status.name}
+
{status.name}
Local
{status.isDefault && Default}
@@ -171,7 +169,7 @@ export function LocalDetail({ status }: { status: LocalServerStatus }) {
-
+
{nameError !== null && {nameError}}
-
-
-
-
-
- Server
-
+
+
+ Connection
+
@@ -212,12 +207,12 @@ export function LocalDetail({ status }: { status: LocalServerStatus }) {
)}
{(actionError ?? error) !== null && (
-
{actionError ?? error}
+
{actionError ?? error}
)}
-
-
Runtime
+
+
Runtime
{cli.failure}}
- CODEWORK_HOME_DIR path
- >
- }
+ title="Home directory"
label="CODEWORK_HOME_DIR path"
- description="codework home and config directory."
+ description="Where this provider stores its sessions and configuration."
// An added server always has its own home, so only the built-in one shows a default to reset to.
value={status.home.fallback === null || status.home.custom ? status.home.path : ""}
placeholder={status.home.fallback ?? "~/.codework-2"}
@@ -249,8 +240,8 @@ export function LocalDetail({ status }: { status: LocalServerStatus }) {
onSave={(next) => configure({ home: next || null })}
/>
Additional CLI arguments passed to codework service start.
@@ -263,15 +254,15 @@ export function LocalDetail({ status }: { status: LocalServerStatus }) {
-
-
Environment
+
+
Environment
-
+
+
+ API keys and other variables for this provider. Changes apply after restarting.
+
-);
-
/** A titled block whose value sits under its description, as in a form. */
function Field({ title, description, children }: { title: ReactNode; description: ReactNode; children?: ReactNode }) {
return (
-
- {title}
- {description}
-
{children}
+
+
+ {title}
+ {description}
+
+
{children}
);
}
@@ -431,20 +420,21 @@ function ServerLine({ status }: { status: LocalServerStatus }) {
if (server._tag === "Running")
return (
<>
- Running at {server.url} · pid {server.pid}
+ Running at {server.url} · pid {server.pid}
>
);
if (server._tag === "Incompatible")
return (
<>
- {server.url} · pid {server.pid} answers, but not in the form this app
- expects: it is another build. Update the CLI, or run a server built with this app.
+ {server.url} · pid {server.pid} answers, but not in the
+ form this app expects: it is another build. Update the CLI, or run a server built with this app.
>
);
if (server._tag === "Unreachable")
return (
<>
- Registered at {server.url} (pid {server.pid}) but not answering this app.
+ Registered at {server.url} (pid {server.pid}) but not
+ answering this app.
>
);
return "Not running. Once started, it keeps running in the background after the app quits.";
diff --git a/apps/web/src/settings/servers/servers.tsx b/apps/web/src/settings/servers/servers.tsx
index 2e2dc5a2..ef1b2b06 100644
--- a/apps/web/src/settings/servers/servers.tsx
+++ b/apps/web/src/settings/servers/servers.tsx
@@ -1,6 +1,7 @@
+import { Minus, Plus, RefreshCw, Undo2 } from "@/components/vuesax";
import { useAtom, useAtomSet, useAtomValue } from "@effect/atom-react";
import { DEFAULT_SERVER_HEALTH, type LocalServerStatus } from "@codeworksh/contract";
-import { Minus, Plus, RefreshCw, Undo2 } from "lucide-react";
+
import { AsyncResult } from "effect/reactivity";
import { useEffect, useState } from "react";
@@ -42,70 +43,66 @@ export function ServersSettings() {
return (
runCheck({ payload: undefined })}
- >
-
- {checking ? "Checking…" : }
-
+
+
+
+
}
>
-
- {/* Side by side, the split keeps a fixed height and each side scrolls on its own; stacked, the page scrolls. */}
-
+ Rename inline; changes save automatically. Drag the handles or use ↑ ↓ while a handle is focused to
+ reorder tabs and shortcuts.
+
+
+ {workspaces.length} of {MAX_WORKSPACES} workspaces
+
+
+ {error !== null && (
+
+ {error}
+
+ )}
-
+
{shortcutLabel(1)}–{MAX_WORKSPACES}
-
+ {isMac ? "⌘," : "Ctrl+,"}
-
+ Esc
@@ -159,11 +246,11 @@ function NameField({ workspace, autoFocus, onError }: RowProps & { readonly auto
const save = useAtomSet(updateWorkspace, { mode: "promise" });
return (
{
// A workspace keeps its name when the field is cleared.
if (!name) return;
@@ -200,8 +287,8 @@ function DeleteButton({ workspace, onError }: RowProps) {
Its tab and shortcut go away.{" "}
{closing === 0
- ? "Widgets shared with other workspaces stay where they are."
- : `${closing} ${closing === 1 ? "widget" : "widgets"} open only here will close; shared widgets stay.`}
+ ? "Panels shared with other workspaces stay open."
+ : `${closing} ${closing === 1 ? "panel" : "panels"} open only here will close; shared panels stay.`}
diff --git a/apps/web/src/shell/page.tsx b/apps/web/src/shell/page.tsx
index 58b6ba95..521152ff 100644
--- a/apps/web/src/shell/page.tsx
+++ b/apps/web/src/shell/page.tsx
@@ -1,6 +1,6 @@
import { RegistryContext } from "@effect/atom-react";
import { HostContext, type Shell, ShellContext } from "@codeworksh/widget/view";
-import type { LucideIcon } from "lucide-react";
+import type { IconComponent } from "@/components/vuesax";
import { type ReactNode, useContext, useLayoutEffect, useMemo, useRef } from "react";
import { makeHost } from "../kernel/host";
@@ -19,7 +19,7 @@ export function Page({
}: {
readonly instanceId: string;
readonly title: string;
- readonly icon: LucideIcon;
+ readonly icon: IconComponent;
readonly children: ReactNode;
}) {
const registry = useContext(RegistryContext);
diff --git a/apps/web/src/shell/palette.tsx b/apps/web/src/shell/palette.tsx
index 76ae6fa7..7507a621 100644
--- a/apps/web/src/shell/palette.tsx
+++ b/apps/web/src/shell/palette.tsx
@@ -1,7 +1,7 @@
+import { Check, ChevronRight, FolderTree, Settings, ThreadsIcon, FolderPlus } from "@/components/vuesax";
import { useAtom, useAtomSet } from "@effect/atom-react";
import { useNavigate } from "@tanstack/react-router";
import { Atom } from "effect/reactivity";
-import { Check, ChevronRight, FolderPlus, FolderTree, ListTodo, Settings } from "lucide-react";
import { useEffect, useState } from "react";
import {
@@ -120,7 +120,7 @@ export function Palette() {
keywords={["all threads", "go", "issues", "list", "status", "priority", "groups"]}
onSelect={() => run(() => void navigate({ to: "/threads" }))}
>
-
+
All Threads
workspace.id === workspaceId) ?? workspaces[0];
- // The workspace settings returns to; the one on screen when it opened.
- const [lastId, setLastId] = useState();
- if (!overDesktop && shown !== undefined && shown.id !== lastId) setLastId(shown.id);
- const back = workspaces.find((workspace) => workspace.id === lastId) ?? workspaces[0];
+ const back = useWorkspaceReturn();
// With every workspace deleted there is nothing to show here; the start page needs none. Only once
// the list has settled: a first load, or the refetch after creating one, must not bounce the user out.
@@ -61,7 +58,7 @@ export function Shell() {
void navigate({ to: "/settings/workspaces" });
} else if (inSettings && event.key === "Escape" && !event.defaultPrevented && !isEditing(event.target)) {
// Dialogs and popovers claim Escape first by preventing its default.
- void navigate(back === undefined ? { to: "/" } : workspaceRoute(back));
+ void navigate(back);
}
};
window.addEventListener("keydown", onKeyDown);
diff --git a/apps/web/src/shell/sources.tsx b/apps/web/src/shell/sources.tsx
index d9717697..e2f979e8 100644
--- a/apps/web/src/shell/sources.tsx
+++ b/apps/web/src/shell/sources.tsx
@@ -1,20 +1,19 @@
-import { RegistryContext, useAtomSet, useAtomValue } from "@effect/atom-react";
-import { GithubIcon } from "@hugeicons/core-free-icons";
-import { HugeiconsIcon } from "@hugeicons/react";
-import { useNavigate } from "@tanstack/react-router";
-import { Cause, Option } from "effect";
-import { AsyncResult } from "effect/reactivity";
+import { ProviderIcon } from "@/components/provider";
import {
ArrowLeft,
+ GitBranch,
+ Link,
CornerDownLeft,
Folder,
FolderGit2,
FolderOpen,
FolderPlus,
FolderUp,
- GitBranch,
- Link,
-} from "lucide-react";
+} from "@/components/vuesax";
+import { RegistryContext, useAtomSet, useAtomValue } from "@effect/atom-react";
+import { useNavigate } from "@tanstack/react-router";
+import { Cause, Option } from "effect";
+import { AsyncResult } from "effect/reactivity";
import { type KeyboardEvent, type ReactNode, useContext, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
@@ -174,7 +173,7 @@ function Sources({ step, setQuery, push, pop, close }: Nav) {
}
+ icon={}
title="GitHub Repository"
detail="Clone GitHub owner/repo"
disabled={status?.ready === false}
@@ -490,7 +489,7 @@ function Remote({ step, setQuery, push, pop, source }: Nav & { source: "url" | "
setQuery(next);
}}
placeholder={github ? "Enter GitHub repository (owner/repo)" : "Enter Git clone URL"}
- icon={github ? : }
+ icon={github ? : }
pop={pop}
onKeyDown={onKeyDown}
/>
diff --git a/apps/web/src/shell/switcher.tsx b/apps/web/src/shell/switcher.tsx
index 51c3f679..c10d7d77 100644
--- a/apps/web/src/shell/switcher.tsx
+++ b/apps/web/src/shell/switcher.tsx
@@ -1,6 +1,6 @@
+import { WorkspaceIcon, X } from "@/components/vuesax";
import { useAtomSet, useAtomValue } from "@effect/atom-react";
import { Link, useNavigate } from "@tanstack/react-router";
-import { AppWindow, X } from "lucide-react";
import { useEffect, useRef } from "react";
import { isMac } from "@codeworksh/widget/view";
@@ -166,7 +166,7 @@ function TabIcon({
const display = useRunDisplay(threads);
return (
- } />
+ } />
);
}
diff --git a/apps/web/src/shell/titlebar.tsx b/apps/web/src/shell/titlebar.tsx
index fed02604..761d16dc 100644
--- a/apps/web/src/shell/titlebar.tsx
+++ b/apps/web/src/shell/titlebar.tsx
@@ -1,10 +1,15 @@
-import { useAtomValue } from "@effect/atom-react";
+import { ArrowLeft, ArrowRight, Plus, X } from "@/components/vuesax";
import { useCanGoBack, useLocation, useNavigate, useParams, useRouter } from "@tanstack/react-router";
-import { ArrowLeft, ArrowRight, Plus, X } from "lucide-react";
import { SidebarTrigger, useSidebar } from "@/components/ui/sidebar";
import { isMac } from "@codeworksh/widget/view";
-import { lastWorkspaceAtom, MAX_WORKSPACES, useCreateWorkspace, useWorkspaces, workspaceRoute } from "../wm/workspaces";
+import {
+ MAX_WORKSPACES,
+ useCreateWorkspace,
+ useWorkspaces,
+ useWorkspaceReturn,
+ workspaceRoute,
+} from "../wm/workspaces";
import { Switcher } from "./switcher";
// The window has no native title bar on macOS; a title bar is its drag region and leaves room for the
@@ -13,7 +18,7 @@ const insetTitleBar = window.desktopBridge?.platform === "darwin";
/** A square title-bar button; it opts out of the window's drag region so it can be clicked. */
const chromeButton =
- "grid size-7 shrink-0 place-items-center rounded-md text-ink-muted press [-webkit-app-region:no-drag] hover:bg-selection-subtle hover:text-ink disabled:opacity-40 disabled:hover:bg-transparent";
+ "grid size-7 shrink-0 place-items-center rounded-md text-ink-muted [-webkit-app-region:no-drag] hover:bg-selection-subtle hover:text-ink disabled:opacity-40 disabled:hover:bg-transparent";
/** Window-level navigation remains available on Home, workspaces, and settings. */
export function NavigationControls() {
@@ -30,8 +35,9 @@ export function NavigationControls() {
return (
workspace.id === lastId);
+ const back = useWorkspaceReturn();
return (
diff --git a/apps/web/src/style.css b/apps/web/src/style.css
index 761b1c3c..8bf84ba1 100644
--- a/apps/web/src/style.css
+++ b/apps/web/src/style.css
@@ -1,4 +1,6 @@
@import "tailwindcss";
+@import "@fontsource-variable/inter/standard.css";
+@import "@fontsource-variable/inter/standard-italic.css";
/* shadcn/ui's enter and exit animations (animate-in, fade-in-0, zoom-in-95, ...). */
@import "tw-animate-css";
@@ -7,12 +9,8 @@
:root {
--cw-desktop: var(--t-bg);
--cw-frame: var(--t-surface);
- /* A touch lighter than the frame (whiter in light palettes), for the panel's top-down gradient. */
- --cw-frame-top: light-dark(
- color-mix(in oklab, var(--t-surface) 88%, white),
- color-mix(in oklab, var(--t-surface) 97%, var(--t-text))
- );
- --cw-edge: color-mix(in srgb, var(--t-text) 7%, transparent);
+ --cw-frame-top: var(--t-surface-2);
+ --cw-edge: var(--t-border-subtle);
--cw-edge-focus: color-mix(in srgb, var(--t-text) 20%, transparent);
--cw-ink: var(--t-text);
--cw-ink-secondary: var(--t-text-secondary);
@@ -47,7 +45,7 @@
--cw-composer-shadow:
0 2px 6px light-dark(color-mix(in srgb, var(--cw-ink) 6%, transparent), transparent),
0 6px 24px light-dark(color-mix(in srgb, var(--cw-ink) 9%, transparent), transparent);
- --cw-scrollbar: color-mix(in oklab, var(--t-text) 16%, transparent);
+ --cw-scrollbar: var(--t-scrollbar);
--cw-danger: light-dark(#dc2626, #f87171);
--cw-warning: light-dark(#d97706, #fbbf24);
--cw-success: light-dark(#16a34a, #4ade80);
@@ -62,8 +60,8 @@
}
@theme {
- /* The platform's own faces (SF Pro and SF Mono on macOS), so the app reads as native. */
- --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
+ /* Inter is bundled locally; code keeps the platform's monospace face. */
+ --font-sans: "Inter Variable", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
--font-mono: ui-monospace, "SF Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace;
/* Tailwind's built-in curves are too weak for UI motion. */
--ease-out: cubic-bezier(0.22, 1, 0.36, 1);
@@ -158,9 +156,10 @@
box-shadow: var(--cw-dialog-shadow);
}
-/* Pressable icon and trigger buttons: a slight press-in on top of their hover colors. */
-@utility press {
- @apply transition-[background-color,color,scale] duration-120 ease-out active:scale-[0.97];
+/* Button feedback is immediate, including icons and labels inside custom controls. */
+:is(button, [role="button"]),
+:is(button, [role="button"]) * {
+ transition: none;
}
@utility dialog-backdrop {
@@ -222,20 +221,10 @@
@apply text-balance;
}
- /* A 6px square thumb on a clear track, widget content included. The standard
- scrollbar-width/color would override these and bottom out at "thin". */
- ::-webkit-scrollbar {
- width: 6px;
- height: 6px;
- }
-
- ::-webkit-scrollbar-track,
- ::-webkit-scrollbar-corner {
- background: transparent;
- }
-
- ::-webkit-scrollbar-thumb {
- background: var(--cw-scrollbar);
+ /* Each scroller owns its thumb color; animated colors never leak into nested scrollers. */
+ * {
+ scrollbar-width: thin;
+ scrollbar-color: transparent transparent;
}
}
@@ -248,10 +237,17 @@
border-radius: 12px 0 0 12px;
background: var(--cw-sidebar);
backdrop-filter: blur(24px);
+ transform: translateX(0);
+ visibility: visible;
+ transition:
+ transform 300ms var(--ease-out),
+ visibility 0s;
}
.app-shell [data-collapsible="offcanvas"] > .shell-sidebar {
- left: calc(0px - var(--sidebar-width));
+ left: 0;
+ transform: translateX(-100%);
visibility: hidden;
+ transition-delay: 0s, 300ms;
}
.app-shell .shell-sidebar [data-slot="sidebar-inner"] {
background: transparent;
@@ -261,12 +257,26 @@
backdrop-filter: none;
}
-/* Sidebar and title-bar tabs follow the same width without trailing one another. */
+/* The sidebar, content edge, and title-bar tabs settle together. */
.app-shell > div > [data-slot="sidebar"] > [data-slot="sidebar-gap"],
-.app-shell .shell-sidebar {
+.app-shell [data-slot="sidebar-navigation"] {
+ transition: width 300ms var(--ease-out);
+}
+
+.app-shell[data-resizing] [data-slot="sidebar-gap"],
+.app-shell[data-resizing] [data-slot="sidebar-navigation"],
+.app-shell[data-resizing] .shell-sidebar {
transition: none;
}
+@media (prefers-reduced-motion: reduce) {
+ .app-shell [data-slot="sidebar-gap"],
+ .app-shell [data-slot="sidebar-navigation"],
+ .app-shell .shell-sidebar {
+ transition: none !important;
+ }
+}
+
/* The transcript continues beneath the floating reply card rather than ending at a hard seam. */
.session-transcript {
mask-image: linear-gradient(
diff --git a/apps/web/src/themes.css b/apps/web/src/themes.css
index 2c26237d..64a058dc 100644
--- a/apps/web/src/themes.css
+++ b/apps/web/src/themes.css
@@ -2,14 +2,15 @@
:root,
[data-theme="dark"] {
- --t-bg: #141414;
- --t-surface: #171717;
- --t-surface-2: #202020;
- --t-border-subtle: #1a1a1a;
+ --t-bg: #09090a;
+ --t-surface: #121213;
+ --t-surface-2: #1b1b1d;
+ --t-border-subtle: #252527;
--t-border-strong: #7a7a7a;
--t-text: #ebebeb;
--t-text-secondary: #c4c4c4;
--t-text-muted: #969696;
+ --t-scrollbar: rgba(235, 235, 235, 0.28);
--t-brand: #4c9ef5;
--t-brand-ink: #171717;
--t-field-bg: #070707;
@@ -22,14 +23,15 @@
}
[data-theme="light"] {
- --t-bg: #efefef;
- --t-surface: #f7f7f7;
- --t-surface-2: #f5f5f5;
- --t-border-subtle: #e8e8e8;
+ --t-bg: #efeff0;
+ --t-surface: #f9f9fa;
+ --t-surface-2: #ffffff;
+ --t-border-subtle: #dedee2;
--t-border-strong: #c0c0c0;
--t-text: #2e2e2e;
--t-text-secondary: #383838;
--t-text-muted: #6d6d6d;
+ --t-scrollbar: rgba(46, 46, 46, 0.28);
--t-brand: #1f6fc4;
--t-brand-ink: #ffffff;
--t-field-bg: #e8e8e8;
diff --git a/apps/web/src/widgets/session/composer.tsx b/apps/web/src/widgets/session/composer.tsx
index f73d217d..58550dad 100644
--- a/apps/web/src/widgets/session/composer.tsx
+++ b/apps/web/src/widgets/session/composer.tsx
@@ -1,6 +1,6 @@
import { useBus, useKv } from "@codeworksh/widget/view";
import { Option, Schema } from "effect";
-import { Square } from "lucide-react";
+import { Square } from "@/components/vuesax";
import { memo, useEffect, useRef, useState } from "react";
import { Composer, ComposerButton, ComposerSubmit, type Submit } from "@/components/composer";
@@ -179,7 +179,7 @@ export const SessionComposer = memo(function SessionComposer({
disabled={stopping}
onClick={() => void stop()}
>
-
+
)}
void navigator.clipboard.writeText(text).then(() => setCopied(true))}
>
diff --git a/apps/web/src/widgets/session/message.tsx b/apps/web/src/widgets/session/message.tsx
index 5cca42e6..9cd81199 100644
--- a/apps/web/src/widgets/session/message.tsx
+++ b/apps/web/src/widgets/session/message.tsx
@@ -1,4 +1,4 @@
-import { Check, Copy, GitFork } from "lucide-react";
+import { Check, Copy, GitFork } from "@/components/vuesax";
import { type ReactNode, useEffect, useState } from "react";
/** Wall-clock stamp for a message or finished turn, in the reader's own locale. */
diff --git a/apps/web/src/widgets/session/queue.tsx b/apps/web/src/widgets/session/queue.tsx
index d1562150..7808909c 100644
--- a/apps/web/src/widgets/session/queue.tsx
+++ b/apps/web/src/widgets/session/queue.tsx
@@ -1,7 +1,15 @@
+import {
+ Pencil,
+ SendHorizontal,
+ Trash2,
+ Undo2,
+ CornerDownRight,
+ ListEnd,
+ type IconComponent,
+} from "@/components/vuesax";
import type { Delivery, QueuedPrompt } from "@codeworksh/widget/codework";
import { useBus } from "@codeworksh/widget/view";
import { Option, Schema } from "effect";
-import { CornerDownRight, ListEnd, type LucideIcon, Pencil, SendHorizontal, Trash2, Undo2 } from "lucide-react";
import { memo, useRef, useState } from "react";
import { Json, type ThreadRef, topics } from "./schema";
@@ -103,11 +111,12 @@ export const Queue = memo(function Queue({
const pending = busy.has(prompt.id);
const draft = editing?.id === prompt.id ? editing.text : undefined;
// While idle both lanes wait for a wake, so the lane action is "send now".
- const lane: { readonly label: string; readonly icon: LucideIcon; readonly delivery: Delivery } = !running
- ? { label: "Send now", icon: SendHorizontal, delivery: "steer" }
- : steer
- ? { label: "Queue as follow-up", icon: ListEnd, delivery: "followUp" }
- : { label: "Steer now", icon: CornerDownRight, delivery: "steer" };
+ const lane: { readonly label: string; readonly icon: IconComponent; readonly delivery: Delivery } =
+ !running
+ ? { label: "Send now", icon: SendHorizontal, delivery: "steer" }
+ : steer
+ ? { label: "Queue as follow-up", icon: ListEnd, delivery: "followUp" }
+ : { label: "Steer now", icon: CornerDownRight, delivery: "steer" };
return (
void;
diff --git a/apps/web/src/widgets/session/view.tsx b/apps/web/src/widgets/session/view.tsx
index 6a7d2757..359927ae 100644
--- a/apps/web/src/widgets/session/view.tsx
+++ b/apps/web/src/widgets/session/view.tsx
@@ -1,10 +1,10 @@
+import { ChevronRight, CircleDashed, X } from "@/components/vuesax";
import { RegistryContext, useAtomSet, useAtomValue } from "@effect/atom-react";
import { type HistoryEntry, type LiveDelta, Part, type QueuedPrompt, type ToolCall } from "@codeworksh/widget/codework";
import { useBus, useEvents, useFrame, useShell, type WidgetProps } from "@codeworksh/widget/view";
import { Option, Schema } from "effect";
import { Atom } from "effect/reactivity";
import { LegendList, type LegendListRenderItemProps } from "@legendapp/list/react";
-import { ChevronRight, CircleDashed, X } from "lucide-react";
import { createContext, memo, use, useEffect, useLayoutEffect, useRef, useState } from "react";
import { Logo } from "@/components/logo";
diff --git a/apps/web/src/widgets/threads/header.tsx b/apps/web/src/widgets/threads/header.tsx
index 4d845324..4794fe00 100644
--- a/apps/web/src/widgets/threads/header.tsx
+++ b/apps/web/src/widgets/threads/header.tsx
@@ -1,6 +1,6 @@
+import { ChevronRight, MoreHorizontal, GripVertical } from "@/components/vuesax";
import { useAtomSet } from "@effect/atom-react";
import type { Grouping } from "@codeworksh/contract";
-import { ChevronRight, GripVertical, MoreHorizontal } from "lucide-react";
import type { DragEvent, ReactNode } from "react";
import {
diff --git a/apps/web/src/widgets/threads/index.ts b/apps/web/src/widgets/threads/index.ts
index 6fcba8b7..bbf10ea8 100644
--- a/apps/web/src/widgets/threads/index.ts
+++ b/apps/web/src/widgets/threads/index.ts
@@ -1,4 +1,4 @@
-import { ListTodo } from "lucide-react";
+import { ThreadsIcon } from "@/components/vuesax";
import { Schema } from "effect";
import { defineWidget } from "@codeworksh/widget/view";
@@ -11,7 +11,7 @@ const threadsAddress = "codework://threads";
export const threads = defineWidget({
kind: "codework:threads",
title: "All Threads",
- icon: ListTodo,
+ icon: ThreadsIcon,
minWidth: 360,
minHeight: 200,
cardinality: "one",
diff --git a/apps/web/src/widgets/threads/page.tsx b/apps/web/src/widgets/threads/page.tsx
index 88c21c2a..55bb42d1 100644
--- a/apps/web/src/widgets/threads/page.tsx
+++ b/apps/web/src/widgets/threads/page.tsx
@@ -1,12 +1,11 @@
-import { ListTodo } from "lucide-react";
-
+import { ThreadsIcon } from "@/components/vuesax";
import { Page } from "../../shell/page";
import { AllThreads } from "./threads";
/** All Threads as a page of its own; its display options are kept under the page's own instance. */
export function ThreadsPage() {
return (
-
+
);
diff --git a/apps/web/src/widgets/threads/picker.tsx b/apps/web/src/widgets/threads/picker.tsx
index ebe13e10..f3f7f42f 100644
--- a/apps/web/src/widgets/threads/picker.tsx
+++ b/apps/web/src/widgets/threads/picker.tsx
@@ -1,4 +1,4 @@
-import { Check, Plus } from "lucide-react";
+import { Check, Plus } from "@/components/vuesax";
import { type ReactNode, useState } from "react";
import {
diff --git a/apps/web/src/widgets/threads/row.tsx b/apps/web/src/widgets/threads/row.tsx
index a04af05d..1580446a 100644
--- a/apps/web/src/widgets/threads/row.tsx
+++ b/apps/web/src/widgets/threads/row.tsx
@@ -1,6 +1,6 @@
+import { TagIcon, Box } from "@/components/vuesax";
import type { Grouping, ListedThread } from "@codeworksh/contract";
import { isMac } from "@codeworksh/widget/view";
-import { Box, Tag } from "lucide-react";
import { type DragEvent, memo, type MouseEvent, type ReactNode } from "react";
import { useRunDisplay } from "../session/display";
@@ -133,7 +133,7 @@ export const Row = memo(function Row({
`Group: ${grouping?.name ?? "No Group"}`,
"L",
grouping === undefined ? (
-
+
) : (
diff --git a/apps/web/src/widgets/threads/toolbar.tsx b/apps/web/src/widgets/threads/toolbar.tsx
index f1a699b9..7885a2d6 100644
--- a/apps/web/src/widgets/threads/toolbar.tsx
+++ b/apps/web/src/widgets/threads/toolbar.tsx
@@ -1,5 +1,5 @@
+import { Check, ListFilter, Plus, SlidersHorizontal, X } from "@/components/vuesax";
import type { Priority, ThreadStatus } from "@codeworksh/contract";
-import { Check, ListFilter, Plus, SlidersHorizontal, X } from "lucide-react";
import type { ReactNode } from "react";
import { Button } from "@/components/ui/button";
diff --git a/apps/web/src/wm/desktop.tsx b/apps/web/src/wm/desktop.tsx
index 2f39c00f..a5b322e0 100644
--- a/apps/web/src/wm/desktop.tsx
+++ b/apps/web/src/wm/desktop.tsx
@@ -408,14 +408,11 @@ export function Desktop({ workspace }: DesktopProps) {
const togglePin = (instance: Instance) =>
setDesk((current) => update(current, instance.id, { pinned: !instance.pinned }));
- const mainClass = "box-border h-full overflow-auto p-1 [scrollbar-gutter:stable_both-edges]";
+ const mainClass = "box-border h-full overflow-auto";
// The first measurement is 0×0; frames committed at it would tween from the origin into their slots.
if (size.width === 0 || size.height === 0) return ;
- // With each frame's p-1 inset, p-1 here gives even 8px gaps at edges and between frames.
- // The scrollbar gutter is always reserved (on both edges, so gaps stay even): if a
- // scrollbar could take width only when it shows, it would shrink the layout, the
- // overflow would vanish with it, and the layout would jitter between both.
+ // Frames meet the workspace edges; the divider supplies its own resize hit area.
return (
void;
}
-/** A frame's full height when collapsed to its header: the header row plus the frame's inset and border. */
-export const FRAME_HEADER_HEIGHT = 46;
+/** The 36px header plus its bottom border, with no outer inset. */
+export const FRAME_HEADER_HEIGHT = 37;
export function Frame(props: FrameProps) {
const { title, lead, icon, rect, collapsed, focused, pinned, maximized, canDrag, offset, animate } = props;
@@ -79,7 +90,7 @@ export function Frame(props: FrameProps) {
-
-
-
-
{/* A collapsed frame keeps its widget mounted, so it keeps working and keeps its state. */}
@@ -129,18 +151,6 @@ export function Frame(props: FrameProps) {
);
}
-/** Up to a third of the header, so the title it leads keeps the room. */
-function Lead({ text }: { readonly text: string }) {
- return (
-
- {text}
-
- /
-
-
- );
-}
-
function Move({
destinations,
onMove,
@@ -157,13 +167,13 @@ function Move({
title="Move"
className={`${actionClass} data-[state=open]:bg-ink/8 data-[state=open]:text-ink`}
>
-
+ Move
{destinations.map(({ id, name, position }) => (
onMove(id)}>
-
+ {name}{shortcutLabel(position)}
@@ -179,10 +189,10 @@ function Move({
}
const actionClass =
- "grid size-6 place-items-center rounded-md text-ink-muted/80 outline-none press hover:bg-ink/8 hover:text-ink focus-visible:ring-2 focus-visible:ring-ring aria-pressed:text-ink";
+ "grid size-6 place-items-center rounded-md text-ink-muted/80 outline-none hover:bg-ink/8 hover:text-ink focus-visible:ring-2 focus-visible:ring-ring aria-pressed:text-ink";
interface ActionProps {
- readonly icon: LucideIcon;
+ readonly icon: IconComponent;
readonly label: string;
readonly active: boolean;
readonly onClick: () => void;
@@ -203,7 +213,7 @@ function Action({ icon: Icon, label, active, onClick }: ActionProps) {
);
}
-/** A frame icon as drawn: an element as given, a Lucide icon at `className`. */
+/** A frame icon as drawn: an element as given, a icon component at `className`. */
export function IconOf({ icon: Icon, className }: { readonly icon: FrameIcon; readonly className?: string }) {
return isValidElement(Icon) ? Icon : ;
}
diff --git a/apps/web/src/wm/overflow.tsx b/apps/web/src/wm/overflow.tsx
index 8c716535..6c90dd04 100644
--- a/apps/web/src/wm/overflow.tsx
+++ b/apps/web/src/wm/overflow.tsx
@@ -1,5 +1,6 @@
+import { ChevronUp } from "@/components/vuesax";
import type { FrameIcon } from "@codeworksh/widget/view";
-import { ChevronUp } from "lucide-react";
+
import type { Rect } from "webwm";
import {
@@ -32,11 +33,11 @@ export function Overflow({
}) {
return (
-
+ +{hidden.length} more
diff --git a/apps/web/src/wm/title.tsx b/apps/web/src/wm/title.tsx
index 5236b652..c75bebe3 100644
--- a/apps/web/src/wm/title.tsx
+++ b/apps/web/src/wm/title.tsx
@@ -3,17 +3,11 @@ interface TitleProps {
readonly className?: string;
}
-/**
- * Shortens in the middle when space runs out, so the end stays readable:
- * "sk-or-v1-20b…fce". Pure CSS: the head truncates, the tail never shrinks.
- */
+/** Session titles read from the start; hovering reveals the full title when it is shortened. */
export function Title({ text, className = "" }: TitleProps) {
- // Short titles only lose their end; longer ones keep their last 4–8 characters.
- const tail = text.length > 12 ? Math.min(8, Math.max(4, Math.floor(text.length / 4))) : 0;
return (
-
- {text.slice(0, text.length - tail)}
- {tail > 0 && {text.slice(-tail)}}
-
+
+ {text}
+
);
}
diff --git a/apps/web/src/wm/workspaces.ts b/apps/web/src/wm/workspaces.ts
index b66e3ac7..46c06730 100644
--- a/apps/web/src/wm/workspaces.ts
+++ b/apps/web/src/wm/workspaces.ts
@@ -37,6 +37,13 @@ export const workspaceRoute = (workspace: Workspace) =>
/** The workspace last on screen, for opening things from outside the desktop (the start page). */
export const lastWorkspaceAtom = Atom.make(undefined).pipe(Atom.keepAlive);
+/** Settings returns to the last workspace still present, or Home when there is none. */
+export function useWorkspaceReturn() {
+ const lastId = useAtomValue(lastWorkspaceAtom);
+ const workspace = useWorkspaces().find(({ id }) => id === lastId);
+ return workspace === undefined ? { to: "/" as const } : workspaceRoute(workspace);
+}
+
/** A workspace route that, once shown, opens `address` there (see the route's `open` search). */
export const openInWorkspaceRoute = (workspace: Workspace, address: string, mode: "replace" | "new") =>
({
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 344e2c1b..ba8913d7 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -303,12 +303,9 @@ importers:
'@effect/atom-react':
specifier: 4.0.5
version: 4.0.5(effect@4.0.5)(react@19.3.1)(scheduler@0.27.0)
- '@hugeicons/core-free-icons':
- specifier: ^4.4.0
- version: 4.4.0
- '@hugeicons/react':
- specifier: ^1.1.10
- version: 1.1.10(react@19.3.1)
+ '@fontsource-variable/inter':
+ specifier: ^5.3.0
+ version: 5.3.0
'@legendapp/list':
specifier: 3.3.5
version: 3.3.5(patch_hash=6cbdacd8c380d9a5afb9f73d4198dc2cc44be6ef28a955b1183c781469307896)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
@@ -333,9 +330,6 @@ importers:
hast-util-to-jsx-runtime:
specifier: 2.3.6
version: 2.3.6(supports-color@7.2.0)
- lucide-react:
- specifier: ^1.52.0
- version: 1.58.0(@types/react@19.3.0)(react@19.3.1)
radix-ui:
specifier: ^1.7.0
version: 1.8.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
@@ -1923,6 +1917,9 @@ packages:
'@fontsource-variable/geist@5.3.0':
resolution: {integrity: sha512-j0m+vLQuG5XAYoHtGCVu0spvlGreR3EzpECUVzkFmI1mTVnAO38l/NEPDCFgZ177JxzYJCLSmTQibIiYPilGrA==}
+ '@fontsource-variable/inter@5.3.0':
+ resolution: {integrity: sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==}
+
'@fontsource-variable/jetbrains-mono@5.3.0':
resolution: {integrity: sha512-F32xpS2NsGYoQi2ADSkKTgpJj7ozajsGgDJ8woTnqjmIB+dxDIqImjl4pXZVEExu8UFZ2ndhmX18EBS/hdz3Lw==}
@@ -1966,14 +1963,6 @@ packages:
peerDependencies:
hono: ^4
- '@hugeicons/core-free-icons@4.4.0':
- resolution: {integrity: sha512-7Mz8bmGoY6tZ6l3C2TXwNMLVSr0I8HPwjjFIERAuJFj3rWwLxdF2ZLkDqdrVuEE1dx5b/VXyIsP4WoSU6Q40KQ==}
-
- '@hugeicons/react@1.1.10':
- resolution: {integrity: sha512-9tAsd92yMVVGP+E+d8cM+wc0VnXuYZHNbWQi7TOe8k2neawmhiaFmVFbzyruejhOMa197/LbgvSED32ayUo3ZA==}
- peerDependencies:
- react: '>=16.0.0'
-
'@iarna/toml@2.2.5':
resolution: {integrity: sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==}
@@ -10920,6 +10909,8 @@ snapshots:
'@fontsource-variable/geist@5.3.0': {}
+ '@fontsource-variable/inter@5.3.0': {}
+
'@fontsource-variable/jetbrains-mono@5.3.0': {}
'@fuma-translate/react@1.0.2(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)':
@@ -10953,12 +10944,6 @@ snapshots:
dependencies:
hono: 4.11.4
- '@hugeicons/core-free-icons@4.4.0': {}
-
- '@hugeicons/react@1.1.10(react@19.3.1)':
- dependencies:
- react: 19.3.1
-
'@iarna/toml@2.2.5': {}
'@img/colour@1.1.0': {}