feat(app): align UI with Codex DLS

Apply DLS tokens, dark mode variables, and refresh Skills/Automations layouts for mock parity.
This commit is contained in:
Benjamin Shafii
2026-02-04 21:04:39 -08:00
parent 4d5eb469d3
commit bd6062c822
26 changed files with 1016 additions and 1046 deletions

View File

@@ -23,7 +23,7 @@
})();
</script>
</head>
<body class="bg-gray-12 text-gray-12">
<body class="bg-dls-surface text-dls-text">
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>

View File

@@ -159,7 +159,7 @@ export default function App() {
location.pathname.toLowerCase().startsWith("/proto-v1-ux")
);
const [tab, setTabState] = createSignal<DashboardTab>("home");
const [tab, setTabState] = createSignal<DashboardTab>("scheduled");
const [settingsTab, setSettingsTab] = createSignal<SettingsTab>("general");
const goToDashboard = (nextTab: DashboardTab, options?: { replace?: boolean }) => {
@@ -3720,6 +3720,7 @@ export default function App() {
id: session.id,
title: session.title,
slug: session.slug,
time: { updated: session.time.updated },
workspaceLabel: workspaceLabelForDirectory(session.directory),
})),
selectSession: selectSession,
@@ -3782,8 +3783,6 @@ export default function App() {
});
const dashboardTabs = new Set<DashboardTab>([
"home",
"sessions",
"scheduled",
"skills",
"plugins",
@@ -3796,7 +3795,7 @@ export default function App() {
if (dashboardTabs.has(normalized as DashboardTab)) {
return normalized as DashboardTab;
}
return "home";
return "scheduled";
};
const initialRoute = () => {
@@ -3846,14 +3845,14 @@ export default function App() {
if (path.startsWith("/proto-v1-ux")) {
if (isTauriRuntime()) {
navigate("/dashboard/home", { replace: true });
navigate("/dashboard/scheduled", { replace: true });
}
return;
}
if (path.startsWith("/proto")) {
if (isTauriRuntime()) {
navigate("/dashboard/home", { replace: true });
navigate("/dashboard/scheduled", { replace: true });
return;
}

View File

@@ -10,14 +10,14 @@ export default function Button(props: ButtonProps) {
const variant = () => local.variant ?? "primary";
const base =
"inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium transition-all duration-200 active:scale-95 focus:outline-none focus:ring-2 focus:ring-gray-6/15 disabled:opacity-50 disabled:cursor-not-allowed";
"inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-colors duration-150 active:scale-[0.98] focus:outline-none focus:ring-2 focus:ring-[rgba(var(--dls-accent-rgb),0.2)] disabled:opacity-50 disabled:cursor-not-allowed";
const variants: Record<NonNullable<ButtonProps["variant"]>, string> = {
primary: "bg-gray-12 text-gray-1 hover:bg-gray-11 shadow-lg shadow-gray-12/5",
secondary: "bg-gray-4 text-gray-12 hover:bg-gray-5 border border-gray-7/50",
ghost: "bg-transparent text-gray-11 hover:text-gray-12 hover:bg-gray-4/50",
outline: "border border-gray-7 text-gray-11 hover:border-gray-7 bg-transparent",
danger: "bg-red-7/10 text-red-11 hover:bg-red-7/20 border border-red-7/20",
primary: "bg-dls-accent text-white hover:bg-[var(--dls-accent-hover)] border border-transparent shadow-[0_1px_2px_rgba(17,24,39,0.12)]",
secondary: "bg-dls-surface text-dls-text hover:bg-dls-hover border border-dls-border",
ghost: "bg-transparent text-dls-secondary hover:text-dls-text hover:bg-dls-hover",
outline: "border border-dls-border text-dls-text hover:bg-dls-hover bg-transparent",
danger: "bg-red-3 text-red-11 hover:bg-red-4 border border-red-6",
};
return (

View File

@@ -8,10 +8,10 @@ type CardProps = {
export default function Card(props: CardProps) {
return (
<div class="rounded-2xl bg-gray-2/50 border border-gray-6/60 backdrop-blur-xl shadow-[0_20px_80px_rgba(0,0,0,0.55)]">
<div class="rounded-xl bg-dls-surface border border-dls-border shadow-[0_1px_2px_rgba(17,24,39,0.06)] transition-shadow hover:shadow-[0_8px_24px_rgba(17,24,39,0.08)]">
{props.title || props.actions ? (
<div class="flex items-center justify-between gap-3 border-b border-gray-6/70 px-5 py-4">
<div class="text-sm font-semibold text-gray-12">{props.title}</div>
<div class="flex items-center justify-between gap-3 border-b border-dls-border px-5 py-4">
<div class="text-sm font-semibold text-dls-text">{props.title}</div>
<div>{props.actions}</div>
</div>
) : null}

View File

@@ -133,18 +133,18 @@ export default function ModelPickerModal(props: ModelPickerModalProps) {
<div class="mt-5">
<div class="relative">
<Search size={16} class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-10" />
<Search size={16} class="absolute left-3 top-1/2 -translate-y-1/2 text-dls-secondary" />
<input
ref={(el) => (searchInputRef = el)}
type="text"
value={props.query}
onInput={(e) => props.setQuery(e.currentTarget.value)}
placeholder={translate("settings.search_models")}
class="w-full bg-gray-1/40 border border-gray-6 rounded-xl py-2.5 pl-9 pr-3 text-sm text-gray-12 placeholder-gray-6 focus:outline-none focus:ring-1 focus:ring-gray-8 focus:border-gray-8"
class="w-full bg-dls-surface border border-dls-border rounded-xl py-2.5 pl-9 pr-3 text-sm text-dls-text placeholder:text-dls-secondary focus:outline-none focus:ring-1 focus:ring-[rgba(var(--dls-accent-rgb),0.2)] focus:border-dls-accent"
/>
</div>
<Show when={props.query.trim()}>
<div class="mt-2 text-xs text-gray-10">
<div class="mt-2 text-xs text-dls-secondary">
{translate("settings.showing_models").replace("{count}", String(props.filteredOptions.length)).replace("{total}", String(props.options.length))}
</div>
</Show>

View File

@@ -59,13 +59,13 @@ export default function OnboardingWorkspaceSelector(props: {
<div class="ml-9">
<div
class={`w-full border border-dashed border-gray-6 bg-gray-1/40 rounded-xl p-4 text-left transition ${
pickingFolder() ? "opacity-70" : "hover:border-zinc-500"
pickingFolder() ? "opacity-70" : "hover:border-dls-active"
}`.trim()}
>
<div class="flex items-center gap-3 text-gray-10">
<FolderPlus size={20} class="text-gray-6" />
<div class="flex items-center gap-3 text-dls-text">
<FolderPlus size={20} class="text-dls-secondary" />
<input
class="flex-1 min-w-0 bg-transparent text-sm font-medium text-gray-10 placeholder-gray-600 focus:outline-none"
class="flex-1 min-w-0 bg-transparent text-sm font-medium text-dls-text placeholder:text-dls-secondary focus:outline-none"
value={selectedFolder()}
onInput={(e) => setSelectedFolder(e.currentTarget.value)}
placeholder={props.defaultPath}
@@ -74,7 +74,7 @@ export default function OnboardingWorkspaceSelector(props: {
type="button"
onClick={handlePickFolder}
disabled={pickingFolder()}
class="text-xs text-gray-6 hover:text-gray-10 transition-colors"
class="text-xs text-dls-secondary hover:text-dls-text transition-colors"
>
<Show
when={pickingFolder()}

View File

@@ -92,7 +92,7 @@ function createCustomRenderer(tone: "light" | "dark") {
href="${safeHref}"
target="_blank"
rel="noopener noreferrer"
class="underline underline-offset-2 text-blue-600 hover:text-blue-700"
class="underline underline-offset-2 text-dls-accent hover:text-[var(--dls-accent-hover)]"
${safeTitle ? `title="${safeTitle}"` : ""}
>
${text}
@@ -337,10 +337,10 @@ export default function PartView(props: Props) {
[&_ul]:list-disc [&_ul]:pl-6 [&_ul]:my-3
[&_ol]:list-decimal [&_ol]:pl-6 [&_ol]:my-3
[&_li]:my-1
[&_blockquote]:border-l-4 [&_blockquote]:border-gray-300 [&_blockquote]:pl-4 [&_blockquote]:my-4 [&_blockquote]:italic
[&_blockquote]:border-l-4 [&_blockquote]:border-dls-border [&_blockquote]:pl-4 [&_blockquote]:my-4 [&_blockquote]:italic
[&_table]:w-full [&_table]:border-collapse [&_table]:my-4
[&_th]:border [&_th]:border-gray-300 [&_th]:p-2 [&_th]:bg-gray-50
[&_td]:border [&_td]:border-gray-300 [&_td]:p-2
[&_th]:border [&_th]:border-dls-border [&_th]:p-2 [&_th]:bg-dls-hover
[&_td]:border [&_td]:border-dls-border [&_td]:p-2
`.trim()}
innerHTML={renderedMarkdown()!}
/>

View File

@@ -183,15 +183,15 @@ export default function QuestionModal(props: QuestionModalProps) {
</div>
<Show when={currentQuestion()!.custom}>
<div class="mt-4 pt-4 border-t border-gray-6/30">
<label class="block text-xs font-semibold text-gray-11 mb-2 uppercase tracking-wide">
<div class="mt-4 pt-4 border-t border-dls-border">
<label class="block text-xs font-semibold text-dls-secondary mb-2 uppercase tracking-wide">
Or type a custom answer
</label>
<input
type="text"
value={customInput()}
onInput={(e) => setCustomInput(e.currentTarget.value)}
class="w-full px-4 py-3 rounded-xl bg-gray-1 border border-gray-6 focus:border-blue-9/50 focus:ring-4 focus:ring-blue-9/10 focus:outline-none text-sm text-gray-12 placeholder-gray-9 transition-shadow"
class="w-full px-4 py-3 rounded-xl bg-dls-surface border border-dls-border focus:border-dls-accent focus:ring-4 focus:ring-[rgba(var(--dls-accent-rgb),0.2)] focus:outline-none text-sm text-dls-text placeholder:text-dls-secondary transition-shadow"
placeholder="Type your answer here..."
onKeyDown={(e) => {
if (e.key === "Enter") {
@@ -204,9 +204,9 @@ export default function QuestionModal(props: QuestionModalProps) {
</Show>
</div>
<div class="p-6 border-t border-gray-6/40 bg-gray-2/50 flex justify-between items-center">
<div class="text-xs text-gray-10 flex items-center gap-2">
<span class="px-1.5 py-0.5 rounded border border-gray-6 bg-gray-3 font-mono"></span>
<div class="p-6 border-t border-dls-border bg-dls-hover flex justify-between items-center">
<div class="text-xs text-dls-secondary flex items-center gap-2">
<span class="px-1.5 py-0.5 rounded border border-dls-border bg-dls-active font-mono"></span>
<span>navigate</span>
<span class="px-1.5 py-0.5 rounded border border-gray-6 bg-gray-3 font-mono ml-2"></span>
<span>select</span>

View File

@@ -1,6 +1,6 @@
import { For, Show, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js";
import type { Agent } from "@opencode-ai/sdk/v2/client";
import { ArrowRight, AtSign, ChevronDown, File, Paperclip, X, Zap } from "lucide-solid";
import { ArrowUp, AtSign, ChevronDown, File, History, Mic, Paperclip, X, Zap } from "lucide-solid";
import type { ComposerAttachment, ComposerDraft, ComposerPart, PromptMode } from "../../types";
@@ -95,7 +95,7 @@ const createMentionSpan = (part: Extract<ComposerPart, { type: "agent" | "file"
span.dataset.mentionValue = part.type === "agent" ? part.name : part.path;
span.dataset.mentionLabel = label;
span.className =
"inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium bg-gray-12/10 text-gray-12 border border-gray-6/70";
"inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium bg-dls-active text-dls-text border border-dls-border";
return span;
};
@@ -761,13 +761,11 @@ export default function Composer(props: ComposerProps) {
});
return (
<div class="p-4 border-t border-gray-6 bg-gray-1 sticky bottom-0 z-20">
<div class="max-w-2xl mx-auto">
<div class="px-4 pb-4 pt-0 bg-dls-surface sticky bottom-0 z-20">
<div class="max-w-3xl mx-auto">
<div
class={`bg-gray-2 border border-gray-6 rounded-3xl overflow-visible transition-all shadow-2xl relative group/input ${
mentionOpen()
? "rounded-t-none border-t-transparent"
: "focus-within:ring-1 focus-within:ring-gray-7"
class={`bg-dls-surface border border-dls-border rounded-2xl shadow-xl overflow-visible transition-all relative group/input ${
mentionOpen() ? "rounded-t-none border-t-transparent" : ""
}`}
onDrop={handleDrop}
onDragOver={(event: DragEvent) => {
@@ -777,16 +775,16 @@ export default function Composer(props: ComposerProps) {
>
<Show when={mentionOpen()}>
<div class="absolute bottom-full left-[-1px] right-[-1px] z-30">
<div class="rounded-t-3xl border border-gray-6 border-b-0 bg-gray-2 shadow-2xl overflow-hidden">
<div class="px-4 pt-3 pb-2 text-[10px] font-semibold uppercase tracking-[0.2em] text-gray-8 border-b border-gray-6/30 bg-gray-2 flex items-center gap-2">
<div class="rounded-t-3xl border border-dls-border border-b-0 bg-dls-surface shadow-2xl overflow-hidden">
<div class="px-4 pt-3 pb-2 text-[10px] font-semibold uppercase tracking-[0.2em] text-dls-secondary border-b border-dls-border bg-dls-surface flex items-center gap-2">
<AtSign size={12} />
Mentions
</div>
<div class="space-y-3 p-3 bg-gray-2 max-h-64 overflow-y-auto">
<div class="space-y-3 p-3 bg-dls-surface max-h-64 overflow-y-auto">
<Show
when={mentionOptions().length}
fallback={
<div class="px-3 py-2 text-xs text-gray-9">
<div class="px-3 py-2 text-xs text-dls-secondary">
{searchLoading() ? "Searching files..." : "No matches found."}
</div>
}
@@ -794,7 +792,7 @@ export default function Composer(props: ComposerProps) {
<For each={mentionSections()}>
{(section: MentionSection) => (
<div>
<div class="px-3 py-1 text-[10px] uppercase tracking-[0.2em] text-gray-8">
<div class="px-3 py-1 text-[10px] uppercase tracking-[0.2em] text-dls-secondary">
{section.title}
</div>
<div class="space-y-1">
@@ -806,8 +804,8 @@ export default function Composer(props: ComposerProps) {
type="button"
class={`w-full flex items-start gap-3 rounded-xl px-3 py-2 text-left transition-colors ${
optionIndex() === mentionIndex()
? "bg-gray-12/10 text-gray-12"
: "text-gray-11 hover:bg-gray-12/5"
? "bg-dls-active text-dls-text"
: "text-dls-text hover:bg-dls-hover"
}`}
onMouseDown={(e: MouseEvent) => {
e.preventDefault();
@@ -815,11 +813,11 @@ export default function Composer(props: ComposerProps) {
}}
onMouseEnter={() => setMentionIndex(optionIndex())}
>
<div class="text-xs font-semibold text-gray-12">
<div class="text-xs font-semibold text-dls-text">
{option.kind === "agent" ? `@${option.label}` : option.label}
</div>
<Show when={option.detail}>
<div class="text-[11px] text-gray-9">{option.detail}</div>
<div class="text-[11px] text-dls-secondary">{option.detail}</div>
</Show>
</button>
);
@@ -835,7 +833,7 @@ export default function Composer(props: ComposerProps) {
</div>
</Show>
<div class="absolute top-3 left-4 flex items-center gap-3 text-[10px] font-bold text-gray-7 uppercase tracking-widest z-10">
<div class="hidden">
<button
type="button"
class="flex items-center gap-1.5 text-gray-7 hover:text-gray-11 transition-colors"
@@ -890,7 +888,7 @@ export default function Composer(props: ComposerProps) {
</div>
</div>
<div class="p-3 pt-8 pb-3 px-4">
<div class="p-3 px-4">
<Show when={props.showNotionBanner}>
<button
type="button"
@@ -906,24 +904,24 @@ export default function Composer(props: ComposerProps) {
<div class="mb-3 flex flex-wrap gap-2">
<For each={attachments()}>
{(attachment: ComposerAttachment) => (
<div class="flex items-center gap-2 rounded-2xl border border-gray-6 bg-gray-1/70 px-3 py-2 text-xs text-gray-11">
<div class="flex items-center gap-2 rounded-2xl border border-dls-border bg-dls-hover px-3 py-2 text-xs text-dls-secondary">
<Show
when={attachment.kind === "image"}
fallback={<File size={14} class="text-gray-9" />}
fallback={<File size={14} class="text-dls-secondary" />}
>
<div class="h-10 w-10 rounded-xl bg-gray-2 overflow-hidden border border-gray-6">
<div class="h-10 w-10 rounded-xl bg-dls-surface overflow-hidden border border-dls-border">
<img src={attachment.dataUrl} alt={attachment.name} class="h-full w-full object-cover" />
</div>
</Show>
<div class="max-w-[160px]">
<div class="truncate text-gray-12">{attachment.name}</div>
<div class="text-[10px] text-gray-9">
<div class="truncate text-dls-text">{attachment.name}</div>
<div class="text-[10px] text-dls-secondary">
{attachment.kind === "image" ? "Image" : attachment.mimeType || "File"}
</div>
</div>
<button
type="button"
class="ml-1 rounded-full p-1 text-gray-9 hover:text-gray-12 hover:bg-gray-12/10"
class="ml-1 rounded-full p-1 text-dls-secondary hover:text-dls-text hover:bg-dls-active"
onClick={() => {
setAttachments((current: ComposerAttachment[]) =>
current.filter((item) => item.id !== attachment.id)
@@ -941,7 +939,7 @@ export default function Composer(props: ComposerProps) {
<div class="relative min-h-[120px]">
<Show when={props.toast}>
<div class="absolute bottom-full right-0 mb-2 z-30 rounded-xl border border-gray-6 bg-gray-1/90 px-3 py-2 text-xs text-gray-11 shadow-lg backdrop-blur-md">
<div class="absolute bottom-full right-0 mb-2 z-30 rounded-xl border border-dls-border bg-dls-surface px-3 py-2 text-xs text-dls-secondary shadow-lg backdrop-blur-md">
{props.toast}
</div>
</Show>
@@ -949,12 +947,12 @@ export default function Composer(props: ComposerProps) {
<div class="flex flex-col gap-2">
<div class="flex-1 min-w-0">
<Show when={props.isRemoteWorkspace}>
<div class="mb-2 text-[10px] uppercase tracking-wider text-gray-8">Remote workspace</div>
<div class="mb-2 text-[10px] uppercase tracking-wider text-dls-secondary">Remote workspace</div>
</Show>
<div class="relative">
<Show when={!props.prompt.trim() && !attachments().length}>
<div class="absolute left-0 top-0 text-gray-6 text-[15px] leading-relaxed pointer-events-none">
<div class="absolute left-0 top-0 text-dls-secondary text-sm leading-relaxed pointer-events-none">
Ask OpenWork...
</div>
</Show>
@@ -971,141 +969,74 @@ export default function Composer(props: ComposerProps) {
onKeyUp={updateMentionQuery}
onClick={updateMentionQuery}
onPaste={handlePaste}
class="bg-transparent border-none p-0 pb-12 pr-20 text-gray-12 focus:ring-0 text-[15px] leading-relaxed resize-none min-h-[24px] outline-none relative z-10"
class="bg-transparent border-none p-0 pb-8 pr-4 text-dls-text focus:ring-0 text-sm leading-relaxed resize-none min-h-[24px] outline-none relative z-10"
/>
<div class="mt-3" ref={props.setAgentPickerRef}>
<button
type="button"
class="flex items-center gap-2 pl-3 pr-2 py-1.5 bg-gray-1/70 border border-gray-6 rounded-lg hover:border-gray-7 hover:bg-gray-3 transition-all group"
onClick={props.onToggleAgentPicker}
aria-expanded={props.agentPickerOpen}
>
<div class="p-1 rounded bg-gray-4 text-gray-10">
<AtSign size={14} />
</div>
<div class="flex flex-col items-start mr-2 min-w-0">
<span class="text-xs font-medium text-gray-12 leading-none truncate max-w-[10rem]">
{props.agentLabel}
</span>
<span class="text-[10px] text-gray-10 font-mono leading-none">
{props.selectedAgent ? "Agent" : "Default"}
</span>
</div>
<ChevronDown size={14} class="text-gray-10 group-hover:text-gray-11" />
</button>
<Show when={props.agentPickerOpen}>
<div class="absolute left-0 bottom-full mb-2 w-72 rounded-2xl border border-gray-6 bg-gray-1/95 shadow-2xl backdrop-blur-md overflow-hidden">
<div class="px-4 pt-3 pb-2 text-[10px] font-semibold uppercase tracking-[0.2em] text-gray-8 border-b border-gray-6/30">
Session agent
</div>
<div class="max-h-64 overflow-auto p-2 space-y-1">
<button
type="button"
class={`w-full flex items-center justify-between rounded-xl px-3 py-2 text-left text-xs transition-colors ${
props.selectedAgent ? "text-gray-11 hover:bg-gray-12/5" : "bg-gray-12/10 text-gray-12"
}`}
onClick={() => props.onSelectAgent(null)}
>
<span>Default agent</span>
<Show when={!props.selectedAgent}>
<span class="text-[10px] uppercase tracking-wider text-gray-9">Active</span>
</Show>
</button>
<Show
when={!props.agentPickerBusy}
fallback={<div class="px-3 py-2 text-xs text-gray-9">Loading agents...</div>}
>
<Show
when={props.agentOptions.length}
fallback={<div class="px-3 py-2 text-xs text-gray-9">No agents available.</div>}
>
<For each={props.agentOptions}>
{(agent: Agent) => (
<button
type="button"
class={`w-full flex items-center justify-between rounded-xl px-3 py-2 text-left text-xs transition-colors ${
props.selectedAgent === agent.name
? "bg-gray-12/10 text-gray-12"
: "text-gray-11 hover:bg-gray-12/5"
}`}
onClick={() => props.onSelectAgent(agent.name)}
>
<span>{agent.name}</span>
<Show when={props.selectedAgent === agent.name}>
<span class="text-[10px] uppercase tracking-wider text-gray-9">Active</span>
</Show>
</button>
)}
</For>
</Show>
</Show>
<Show when={props.agentPickerError}>
<div class="px-3 py-2 text-xs text-red-11">{props.agentPickerError}</div>
</Show>
</div>
<div class="border-t border-gray-6/40 px-4 py-2 text-[10px] text-gray-9">
Tip: use /agent-next or /agent-prev to cycle.
</div>
</div>
</Show>
</div>
<Show when={!props.prompt.trim() && !attachments().length}>
<div class="mt-2 text-[10px] text-gray-8">
Enter to send · Shift+Enter for newline
<div class="mt-3 flex items-center justify-between px-2 pb-2">
<div class="flex items-center gap-2">
<input
ref={fileInputRef}
type="file"
multiple
accept={ACCEPTED_FILE_TYPES.join(",")}
class="hidden"
disabled={attachmentsDisabled()}
onChange={(event: Event) => {
const target = event.currentTarget as HTMLInputElement;
const files = Array.from(target.files ?? []);
if (files.length) void addAttachments(files);
target.value = "";
}}
/>
<button
type="button"
class={`p-1.5 hover:bg-dls-hover rounded-md text-dls-secondary transition-colors ${
attachmentsDisabled() ? "cursor-not-allowed" : ""
}`}
onClick={() => {
if (attachmentsDisabled()) return;
fileInputRef?.click();
}}
disabled={attachmentsDisabled()}
title={
attachmentsDisabled()
? props.attachmentsDisabledReason ?? "Attachments are unavailable."
: "Attach files"
}
>
<Paperclip size={16} />
</button>
<button
type="button"
class="flex items-center gap-1.5 px-2 py-1 hover:bg-dls-hover rounded-md text-xs font-medium text-dls-secondary hover:text-dls-text"
onClick={props.onModelClick}
disabled={props.busy}
>
{props.selectedModelLabel}
<ChevronDown size={14} />
</button>
</div>
<div class="flex items-center gap-3 text-dls-secondary">
<button type="button" class="cursor-pointer hover:text-dls-text">
<History size={18} />
</button>
<button type="button" class="cursor-pointer hover:text-dls-text">
<Mic size={18} />
</button>
<button
type="button"
disabled={!props.prompt.trim() && !attachments().length}
onClick={sendDraft}
class={`p-1.5 rounded-full ${
!props.prompt.trim() && !attachments().length
? "bg-dls-active text-dls-secondary"
: "bg-dls-accent text-white"
}`}
title="Send"
>
<ArrowUp size={18} />
</button>
</div>
</Show>
<div class="absolute bottom-0 right-0 z-20 flex items-center gap-2">
<input
ref={fileInputRef}
type="file"
multiple
accept={ACCEPTED_FILE_TYPES.join(",")}
class="hidden"
disabled={attachmentsDisabled()}
onChange={(event: Event) => {
const target = event.currentTarget as HTMLInputElement;
const files = Array.from(target.files ?? []);
if (files.length) void addAttachments(files);
target.value = "";
}}
/>
<button
type="button"
class={`p-2 rounded-xl border transition-colors ${
attachmentsDisabled()
? "border-gray-6 text-gray-7 cursor-not-allowed"
: "border-gray-6 text-gray-10 hover:text-gray-12 hover:border-gray-7"
}`}
onClick={() => {
if (attachmentsDisabled()) return;
fileInputRef?.click();
}}
disabled={attachmentsDisabled()}
title={
attachmentsDisabled()
? props.attachmentsDisabledReason ?? "Attachments are unavailable."
: "Attach files"
}
>
<Paperclip size={16} />
</button>
<button
disabled={!props.prompt.trim() && !attachments().length}
onClick={sendDraft}
class={`p-2 rounded-xl transition-all shadow-lg shrink-0 flex items-center justify-center ${
!props.prompt.trim() && !attachments().length
? "bg-gray-4 text-gray-8 cursor-not-allowed"
: "bg-gray-12 text-gray-1 hover:scale-105 active:scale-95"
}`}
title="Run"
>
<ArrowRight size={18} />
</button>
</div>
</div>
</div>

View File

@@ -228,7 +228,7 @@ export default function MessageList(props: MessageListProps) {
);
return (
<div class="max-w-3xl mx-auto space-y-6 pb-32 px-4">
<div class="space-y-6 pb-32">
<For each={messageBlocks()}>
{(block) => {
if (block.kind === "steps-cluster") {
@@ -380,7 +380,7 @@ export default function MessageList(props: MessageListProps) {
</For>
<div class="absolute bottom-2 right-2 flex justify-end opacity-100 pointer-events-auto md:opacity-0 md:pointer-events-none md:group-hover:opacity-100 md:group-hover:pointer-events-auto md:group-focus-within:opacity-100 md:group-focus-within:pointer-events-auto transition-opacity select-none">
<button
class="text-gray-9 hover:text-gray-11 p-1 rounded hover:bg-black/5 dark:hover:bg-white/10 transition-colors"
class="text-dls-secondary hover:text-dls-text p-1 rounded hover:bg-dls-hover transition-colors"
title="Copy message"
onClick={() => {
const text = block.renderableParts

View File

@@ -188,7 +188,7 @@ export default function StatusBar(props: StatusBarProps) {
return (
<div class="border-t border-gray-6 bg-gray-1/90 backdrop-blur-md">
<div class="mx-auto max-w-5xl px-4 py-2 flex flex-wrap items-center gap-3 text-xs">
<div class="px-4 py-2 flex flex-wrap items-center gap-3 text-xs">
<div
class="flex items-center gap-2"
title={`OpenCode Engine: ${opencodeStatusMeta().label}`}
@@ -213,20 +213,6 @@ export default function StatusBar(props: StatusBarProps) {
</Show>
</div>
<div class="ml-auto flex items-center gap-2">
<Button
variant="ghost"
class="h-7 px-2.5 py-0 text-xs"
onClick={props.onOpenMessaging}
title={messagingMeta().label}
>
<span class="relative">
<MessageCircle class={`w-4 h-4 ${messagingMeta().text}`} />
<span class={`absolute -right-1 -bottom-1 w-2 h-2 rounded-full ${messagingMeta().dot}`} />
</span>
<Show when={props.developerMode}>
<span class="text-gray-11 font-medium">Messaging</span>
</Show>
</Button>
<Show when={tipVisible() && activeTip()}>
<button
type="button"

View File

@@ -11,16 +11,16 @@ export default function TextInput(props: TextInputProps) {
return (
<label class="block">
{local.label ? (
<div class="mb-1 text-xs font-medium text-gray-11">{local.label}</div>
<div class="mb-1 text-xs font-medium text-dls-secondary">{local.label}</div>
) : null}
<input
{...rest}
ref={local.ref}
class={`w-full rounded-xl bg-gray-2/60 px-3 py-2 text-sm text-gray-12 placeholder:text-gray-10 shadow-[0_0_0_1px_rgba(255,255,255,0.08)] focus:outline-none focus:ring-2 focus:ring-gray-6/20 ${
class={`w-full rounded-lg bg-dls-surface px-3 py-2 text-sm text-dls-text placeholder:text-dls-secondary border border-dls-border shadow-sm focus:outline-none focus:ring-2 focus:ring-[rgba(var(--dls-accent-rgb),0.2)] ${
local.class ?? ""
}`.trim()}
/>
{local.hint ? <div class="mt-1 text-xs text-gray-10">{local.hint}</div> : null}
{local.hint ? <div class="mt-1 text-xs text-dls-secondary">{local.hint}</div> : null}
</label>
);
}

View File

@@ -30,7 +30,7 @@ export function LocalProvider(props: ParentProps) {
Persist.global("local.ui", ["openwork.ui"]),
createStore<LocalUIState>({
view: "onboarding",
tab: "home",
tab: "scheduled",
}),
);

View File

@@ -876,7 +876,7 @@ export function createWorkspaceStore(options: {
options.refreshSkills({ force: true }).catch(() => undefined);
options.refreshPlugins().catch(() => undefined);
if (!options.selectedSessionId()) {
options.setTab("home");
options.setTab("scheduled");
options.setView("session");
}
@@ -946,7 +946,7 @@ export function createWorkspaceStore(options: {
}
setCreateWorkspaceOpen(false);
options.setTab("home");
options.setTab("scheduled");
options.setView("dashboard");
markOnboardingComplete();
} catch (e) {
@@ -1386,7 +1386,7 @@ export function createWorkspaceStore(options: {
syncActiveWorkspaceId(ws.activeId);
setCreateWorkspaceOpen(false);
setCreateRemoteWorkspaceOpen(false);
options.setTab("home");
options.setTab("scheduled");
options.setView("dashboard");
markOnboardingComplete();

View File

@@ -5,10 +5,32 @@
:root {
color-scheme: light;
--dls-surface: #ffffff;
--dls-sidebar: #f3f3f3;
--dls-border: #f3f3f3;
--dls-accent: #2563eb;
--dls-accent-hover: #1d4ed8;
--dls-accent-rgb: 37 99 235;
--dls-text-primary: #111827;
--dls-text-secondary: #6b7280;
--dls-hover: #f9fafb;
--dls-active: #e5e5e7;
--dls-radius: 8px;
--dls-radius-lg: 16px;
}
[data-theme="dark"] {
color-scheme: dark;
--dls-surface: #121212;
--dls-sidebar: #1a1a1a;
--dls-border: #262626;
--dls-accent: #3b82f6;
--dls-accent-hover: #2563eb;
--dls-accent-rgb: 59 130 246;
--dls-text-primary: #ededed;
--dls-text-secondary: #9ca3af;
--dls-hover: #1f1f1f;
--dls-active: #2d2d2d;
}
html,
@@ -19,6 +41,7 @@ body {
body {
margin: 0;
font-family:
Inter,
ui-sans-serif,
system-ui,
-apple-system,
@@ -30,6 +53,10 @@ body {
"Noto Sans",
"Apple Color Emoji",
"Segoe UI Emoji";
font-size: 14px;
line-height: 1.5;
color: var(--dls-text-primary);
background-color: var(--dls-surface);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

View File

@@ -24,7 +24,6 @@ import type {
import type { EngineInfo, OpenwrkStatus, OpenworkServerInfo, OwpenbotInfo, WorkspaceInfo } from "../lib/tauri";
import Button from "../components/button";
import OpenWorkLogo from "../components/openwork-logo";
import McpView from "./mcp";
import PluginsView from "./plugins";
import ScheduledTasksView from "./scheduled";
@@ -32,7 +31,17 @@ import SettingsView from "./settings";
import SkillsView from "./skills";
import StatusBar from "../components/status-bar";
import ProviderAuthModal from "../components/provider-auth-modal";
import { Command, Cpu, Calendar, Package, Play, Server } from "lucide-solid";
import {
Box,
ChevronRight,
Edit2,
History,
Layout,
MoreHorizontal,
Plus,
Settings,
Zap,
} from "lucide-solid";
export type DashboardViewProps = {
tab: DashboardTab;
@@ -213,30 +222,40 @@ export type DashboardViewProps = {
export default function DashboardView(props: DashboardViewProps) {
const title = createMemo(() => {
switch (props.tab) {
case "sessions":
return "Sessions";
case "scheduled":
return "Scheduled Tasks";
return "Automations";
case "skills":
return "Skills";
case "plugins":
return "Plugins";
case "mcp":
return "MCPs";
return "Apps";
case "settings":
return "Settings";
default:
return "Dashboard";
return "Automations";
}
});
const canExportWorkspace = createMemo(() => props.activeWorkspaceDisplay.workspaceType !== "remote");
const workspaceStatus = createMemo(() => {
switch (props.openworkServerStatus) {
case "connected":
return { label: "Live", className: "bg-emerald-3 text-emerald-11" };
case "limited":
return { label: "Limited", className: "bg-amber-3 text-amber-11" };
case "disconnected":
default:
return { label: "Offline", className: "bg-red-3 text-red-11" };
}
});
const workspaceTypeLabel = createMemo(() =>
props.activeWorkspaceDisplay.workspaceType === "remote" ? "Remote" : "Local"
);
const openSessionFromList = (sessionId: string) => {
// Defer view switch to avoid click-through on the same event frame.
window.setTimeout(() => {
void props.selectSession(sessionId);
props.setTab("sessions");
props.setView("session", sessionId);
}, 0);
};
@@ -244,21 +263,13 @@ export default function DashboardView(props: DashboardViewProps) {
// Track last refreshed tab to avoid duplicate calls
const [lastRefreshedTab, setLastRefreshedTab] = createSignal<string | null>(null);
const [refreshInProgress, setRefreshInProgress] = createSignal(false);
const [taskDraft, setTaskDraft] = createSignal("");
const [providerAuthActionBusy, setProviderAuthActionBusy] = createSignal(false);
const canCreateTask = createMemo(
() => !props.newTaskDisabled && taskDraft().trim().length > 0
const [sessionsExpanded, setSessionsExpanded] = createSignal(true);
const [showAllSessions, setShowAllSessions] = createSignal(false);
const visibleSessions = createMemo(() =>
showAllSessions() ? props.sessions : props.sessions.slice(0, 5)
);
const startTask = () => {
const value = taskDraft().trim();
if (!value || props.newTaskDisabled) return;
props.setPrompt(value);
props.createSessionAndOpen();
setTaskDraft("");
};
const handleProviderAuthSelect = async (providerId: string) => {
if (providerAuthActionBusy()) return;
setProviderAuthActionBusy(true);
@@ -318,13 +329,6 @@ export default function DashboardView(props: DashboardViewProps) {
if (currentTab === "scheduled" && !cancelled) {
await props.refreshScheduledJobs();
}
if (currentTab === "sessions" && !cancelled) {
// Stagger these calls to avoid request stacking
await props.refreshSkills();
if (!cancelled) {
await props.refreshPlugins("project");
}
}
} catch {
// Ignore errors during navigation
} finally {
@@ -346,10 +350,10 @@ export default function DashboardView(props: DashboardViewProps) {
const active = () => props.tab === t;
return (
<button
class={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-colors ${
class={`w-full h-10 flex items-center gap-3 px-3 rounded-lg text-sm font-medium transition-colors ${
active()
? "bg-gray-2 text-gray-12"
: "text-gray-10 hover:text-gray-12 hover:bg-gray-2/50"
? "bg-dls-active text-dls-text"
: "text-dls-secondary hover:text-dls-text hover:bg-dls-hover"
}`}
onClick={() => props.setTab(t)}
>
@@ -365,274 +369,186 @@ export default function DashboardView(props: DashboardViewProps) {
};
return (
<div class="flex h-screen bg-gray-1 text-gray-12 overflow-hidden">
<aside class="w-64 border-r border-gray-6 p-6 hidden md:flex flex-col justify-between bg-gray-1">
<div>
<div class="flex items-center gap-3 mb-10 px-2">
<div class="">
<OpenWorkLogo size={32} />
</div>
<span class="font-bold text-lg tracking-tight">OpenWork</span>
</div>
<nav class="space-y-1">
{navItem("home", "Dashboard", <Command size={18} />)}
{navItem("sessions", "Sessions", <Play size={18} />)}
{navItem("scheduled", "Scheduled Tasks", <Calendar size={18} />)}
{navItem("skills", "Skills", <Package size={18} />)}
{navItem("plugins", "Plugins", <Cpu size={18} />)}
{navItem(
"mcp",
<span class="inline-flex items-center gap-2">
MCPs
<span class="text-[10px] uppercase tracking-wide px-2 py-0.5 rounded-full bg-amber-7/20 text-amber-12">
Alpha
</span>
</span>,
<Server size={18} />,
)}
</nav>
<div class="flex h-screen w-full bg-dls-surface text-dls-text font-sans overflow-hidden">
<aside class="w-64 hidden md:flex flex-col bg-dls-sidebar border-r border-dls-border p-4">
<div class="space-y-0.5 mb-6 pt-2">
{navItem("scheduled", "Automations", <History size={18} />)}
{navItem("skills", "Skills", <Zap size={18} />)}
{navItem("mcp", "Apps", <Box size={18} />)}
</div>
<div class="space-y-4">
<div class="space-y-2 mb-6">
<div class="flex items-center justify-between text-[11px] font-bold text-dls-secondary uppercase px-3 tracking-tight">
<span>Workspace</span>
<button
type="button"
aria-label="Workspace settings"
onClick={() => openSettings("general")}
class="text-dls-secondary hover:text-dls-text"
>
<Settings size={14} />
</button>
</div>
<div class="rounded-lg border border-dls-border bg-dls-surface px-3 py-2">
<div class="text-sm font-semibold text-dls-text truncate">
{props.activeWorkspaceDisplay.name}
</div>
<div class="mt-1 flex items-center gap-2 text-xs text-dls-secondary">
<span>{workspaceTypeLabel()}</span>
<span>{workspaceStatus().label}</span>
</div>
</div>
</div>
<Show
when={
props.activeWorkspaceDisplay.workspaceType === "remote" &&
props.openworkServerStatus === "disconnected"
}
<div class="flex-1 overflow-y-auto">
<div class="flex items-center justify-between text-[11px] font-bold text-dls-secondary uppercase px-3 mb-3 tracking-tight">
<span>Sessions</span>
<div class="flex gap-2 text-dls-secondary">
<button type="button" class="hover:text-dls-text" aria-label="Session layout">
<Layout size={14} />
</button>
<button
type="button"
class="hover:text-dls-text"
aria-label="New session"
onClick={props.createSessionAndOpen}
disabled={props.newTaskDisabled}
>
<Plus size={14} />
</button>
</div>
</div>
<div class="mb-2">
<div
role="button"
tabIndex={0}
onClick={() => setSessionsExpanded((current) => !current)}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
setSessionsExpanded((current) => !current);
}}
class="group flex items-center justify-between h-8 px-3 rounded-lg cursor-pointer text-dls-text transition-colors hover:bg-dls-hover"
>
<div class="flex items-center gap-2">
<ChevronRight
size={14}
class={`text-dls-secondary transition-transform ${
sessionsExpanded() ? "rotate-90" : ""
}`}
/>
<span class="text-sm font-medium">{props.activeWorkspaceDisplay.name}</span>
</div>
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
class="p-1 hover:bg-dls-active rounded-md text-dls-secondary transition-colors"
onClick={(event) => event.stopPropagation()}
aria-label="Workspace options"
>
<MoreHorizontal size={14} />
</button>
</div>
</div>
<Show when={sessionsExpanded()}>
<div class="mt-0.5 space-y-0.5 border-l border-dls-border ml-4">
<Show
when={props.sessions.length > 0}
fallback={
<div class="px-3 py-2 text-xs text-dls-secondary">
No sessions yet.
</div>
}
>
<For each={visibleSessions()}>
{(session) => (
<div
role="button"
tabIndex={0}
class="group flex items-center justify-between h-8 px-3 hover:bg-dls-hover rounded-lg cursor-pointer relative overflow-hidden ml-5 w-[calc(100%-1.25rem)]"
onClick={() => openSessionFromList(session.id)}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
openSessionFromList(session.id);
}}
>
<span class="text-sm text-dls-text truncate mr-2 font-medium group-hover:pr-14 transition-all">
{session.title}
</span>
<span class="text-xs text-dls-secondary whitespace-nowrap group-hover:opacity-0 transition-opacity">
{formatRelativeTime(session.time.updated)}
</span>
<div class="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
class="p-1 hover:bg-dls-active rounded-md text-dls-text transition-colors"
onClick={(event) => event.stopPropagation()}
aria-label="Session options"
>
<MoreHorizontal size={14} />
</button>
<button
type="button"
class="p-1 hover:bg-dls-active rounded-md text-dls-text transition-colors"
onClick={(event) => event.stopPropagation()}
aria-label="Rename session"
>
<Edit2 size={14} />
</button>
</div>
</div>
)}
</For>
</Show>
</div>
</Show>
</div>
<Show when={props.sessions.length > 5}>
<button
type="button"
onClick={() => setShowAllSessions((current) => !current)}
class="px-3 py-1.5 text-xs text-dls-secondary hover:text-dls-text font-medium"
>
{showAllSessions() ? "Show less" : "Show more"}
</button>
</Show>
</div>
<div class="pt-4 border-t border-dls-border">
<button
type="button"
onClick={() => openSettings("general")}
class="flex items-center gap-3 px-3 py-2 rounded-lg text-dls-secondary hover:bg-dls-hover transition-colors"
>
<div class="text-[11px] text-gray-9 px-1">
OpenWork server is offline remote tasks still run.
</div>
</Show>
<Show when={!props.clientConnected}>
<div class="text-[11px] text-gray-9 px-1">
Add a workspace from the Sessions sidebar to get started.
</div>
</Show>
<Settings size={18} />
<span class="text-sm font-medium">Settings</span>
</button>
</div>
</aside>
<main class="flex-1 overflow-y-auto relative pb-24 md:pb-12">
<header class="h-16 flex items-center justify-between px-6 md:px-10 border-b border-gray-6 sticky top-0 bg-gray-1/80 backdrop-blur-md z-10">
<main class="flex-1 overflow-y-auto relative pb-24 md:pb-12 bg-dls-surface">
<header class="h-14 flex items-center justify-between px-6 md:px-10 border-b border-dls-border sticky top-0 bg-dls-surface z-10">
<div class="flex items-center gap-3">
<div class="px-3 py-1.5 rounded-xl bg-gray-2 text-xs text-gray-11 font-medium">
<div class="px-3 py-1.5 rounded-xl bg-dls-hover text-xs text-dls-secondary font-medium">
{props.activeWorkspaceDisplay.name}
</div>
<h1 class="text-lg font-medium">{title()}</h1>
<Show when={props.developerMode}>
<span class="text-xs text-gray-7">{props.headerStatus}</span>
<span class="text-xs text-dls-secondary">{props.headerStatus}</span>
</Show>
<Show when={props.busyHint}>
<span class="text-xs text-gray-10">{props.busyHint}</span>
<span class="text-xs text-dls-secondary">{props.busyHint}</span>
</Show>
</div>
<div class="flex items-center gap-2">
<Show when={props.tab === "home" || props.tab === "sessions"}>
<Button
variant="outline"
class="text-xs h-9"
onClick={props.exportWorkspaceConfig}
disabled={!canExportWorkspace() || props.exportWorkspaceBusy}
title={
!canExportWorkspace()
? "Export is only available for local workspaces"
: "Export workspace config"
}
>
Share config
</Button>
<Button
onPointerDown={(e) => {
e.currentTarget.setPointerCapture?.(e.pointerId);
}}
onPointerUp={() => {
console.log("[DEBUG] new task button pointerup");
props.createSessionAndOpen();
}}
disabled={props.newTaskDisabled}
title={props.newTaskDisabled ? props.busyHint ?? "Busy" : ""}
>
<Play size={16} />
New Task
</Button>
</Show>
</div>
<div class="flex items-center gap-2" />
</header>
<div class="p-6 md:p-10 max-w-5xl mx-auto space-y-10">
<Switch>
<Match when={props.tab === "home"}>
<section>
<div class="bg-gradient-to-r from-gray-2 to-gray-4 rounded-3xl p-1 ">
<div class="bg-gray-1 rounded-[22px] p-6 md:p-8 flex flex-col md:flex-row items-center justify-between gap-6">
<div class="space-y-2 text-center md:text-left">
<h2 class="text-2xl font-semibold text-gray-12">
What should we do today?
</h2>
<p class="text-gray-11">
Describe an outcome. OpenWork will run it and keep an
audit trail.
</p>
</div>
<div class="w-full md:w-[360px]">
<div class="flex items-center gap-2 rounded-2xl border border-gray-6/60 bg-gray-2/50 px-4 py-3 shadow-lg shadow-gray-12/5 focus-within:border-gray-7 focus-within:bg-gray-2 transition-all">
<input
value={taskDraft()}
onInput={(event) => setTaskDraft(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
startTask();
}
}}
placeholder="Draft a task to run..."
class="flex-1 bg-transparent border-none p-0 text-sm text-gray-12 placeholder-gray-7 focus:ring-0"
aria-label="Describe a task"
disabled={props.newTaskDisabled}
/>
<button
type="button"
onClick={startTask}
disabled={!canCreateTask()}
class="rounded-xl bg-gray-12 px-3 py-1.5 text-xs font-semibold text-gray-1 shadow-md transition-all hover:scale-[1.02] active:scale-95 disabled:opacity-40 disabled:hover:scale-100"
title={
props.newTaskDisabled ? props.busyHint ?? "Busy" : ""
}
>
Run
</button>
</div>
<div class="mt-2 text-[11px] text-gray-9 text-center md:text-left">
Press Enter to start a new session.
</div>
</div>
</div>
</div>
</section>
<section>
<h3 class="text-sm font-medium text-gray-11 uppercase tracking-wider mb-4">
Recent Sessions
</h3>
<div class="bg-gray-2/30 border border-gray-6/50 rounded-2xl overflow-hidden">
<For each={props.sessions.slice(0, 3)}>
{(s, idx) => (
<button
class={`w-full p-4 flex items-center justify-between hover:bg-gray-4/50 transition-colors text-left ${
idx() !== Math.min(props.sessions.length, 3) - 1
? "border-b border-gray-6/50"
: ""
}`}
onPointerDown={(e) => {
e.currentTarget.setPointerCapture?.(e.pointerId);
}}
onPointerUp={() => {
openSessionFromList(s.id);
}}
>
<div class="flex items-center gap-4">
<div class="w-8 h-8 rounded-full bg-gray-4 flex items-center justify-center text-xs text-gray-10 font-mono">
#{s.slug?.slice(0, 2) ?? ".."}
</div>
<div>
<div class="font-medium text-sm text-gray-12">
{s.title}
</div>
<div class="text-xs text-gray-10 flex items-center gap-2">
<span class="flex items-center gap-1">
{formatRelativeTime(s.time.updated)}
</span>
</div>
</div>
</div>
<div class="flex items-center gap-4">
<span class="text-xs px-2 py-0.5 rounded-full border border-gray-7/60 text-gray-11 flex items-center gap-1.5">
<span class="w-1.5 h-1.5 rounded-full bg-current" />
{props.sessionStatusById[s.id] ?? "idle"}
</span>
</div>
</button>
)}
</For>
<Show when={!props.sessions.length}>
<div class="p-6 text-sm text-gray-10 space-y-3">
<div>No sessions yet.</div>
<Button
variant="secondary"
class="text-xs h-8"
onClick={props.createSessionAndOpen}
disabled={props.newTaskDisabled}
>
Start a task
</Button>
</div>
</Show>
</div>
</section>
</Match>
<Match when={props.tab === "sessions"}>
<section>
<h3 class="text-sm font-medium text-gray-11 uppercase tracking-wider mb-4">
Sessions
</h3>
<div class="bg-gray-2/30 border border-gray-6/50 rounded-2xl overflow-hidden">
<For each={props.sessions}>
{(s, idx) => (
<button
class={`w-full p-4 flex items-center justify-between hover:bg-gray-4/50 transition-colors text-left ${
idx() !== props.sessions.length - 1
? "border-b border-gray-6/50"
: ""
}`}
onPointerDown={(e) => {
e.currentTarget.setPointerCapture?.(e.pointerId);
}}
onPointerUp={() => {
openSessionFromList(s.id);
}}
>
<div class="flex items-center gap-4">
<div class="w-8 h-8 rounded-full bg-gray-4 flex items-center justify-center text-xs text-gray-10 font-mono">
#{s.slug?.slice(0, 2) ?? ".."}
</div>
<div>
<div class="font-medium text-sm text-gray-12">
{s.title}
</div>
<div class="text-xs text-gray-10 flex items-center gap-2">
<span class="flex items-center gap-1">
{formatRelativeTime(s.time.updated)}
</span>
</div>
</div>
</div>
<div class="flex items-center gap-4">
<span class="text-xs px-2 py-0.5 rounded-full border border-gray-7/60 text-gray-11 flex items-center gap-1.5">
<span class="w-1.5 h-1.5 rounded-full bg-current" />
{props.sessionStatusById[s.id] ?? "idle"}
</span>
</div>
</button>
)}
</For>
<Show when={!props.sessions.length}>
<div class="p-6 text-sm text-gray-10">
No sessions yet.
</div>
</Show>
</div>
</section>
</Match>
<Match when={props.tab === "scheduled"}>
<ScheduledTasksView
jobs={props.scheduledJobs}
@@ -843,34 +759,16 @@ export default function DashboardView(props: DashboardViewProps) {
providerConnectedIds={props.providerConnectedIds}
mcpStatuses={props.mcpStatuses}
/>
<nav class="md:hidden border-t border-gray-6 bg-gray-1/90 backdrop-blur-md">
<div class="mx-auto max-w-5xl px-4 py-3 grid grid-cols-7 gap-2">
<button
class={`flex flex-col items-center gap-1 text-xs ${
props.tab === "home" ? "text-gray-12" : "text-gray-10"
}`}
onClick={() => props.setTab("home")}
>
<Command size={18} />
Home
</button>
<button
class={`flex flex-col items-center gap-1 text-xs ${
props.tab === "sessions" ? "text-gray-12" : "text-gray-10"
}`}
onClick={() => props.setTab("sessions")}
>
<Play size={18} />
Runs
</button>
<nav class="md:hidden border-t border-dls-border bg-dls-surface">
<div class="mx-auto max-w-5xl px-4 py-3 grid grid-cols-3 gap-2">
<button
class={`flex flex-col items-center gap-1 text-xs ${
props.tab === "scheduled" ? "text-gray-12" : "text-gray-10"
}`}
onClick={() => props.setTab("scheduled")}
>
<Calendar size={18} />
Schedule
<History size={18} />
Automations
</button>
<button
class={`flex flex-col items-center gap-1 text-xs ${
@@ -878,26 +776,17 @@ export default function DashboardView(props: DashboardViewProps) {
}`}
onClick={() => props.setTab("skills")}
>
<Package size={18} />
<Zap size={18} />
Skills
</button>
<button
class={`flex flex-col items-center gap-1 text-xs ${
props.tab === "plugins" ? "text-gray-12" : "text-gray-10"
}`}
onClick={() => props.setTab("plugins")}
>
<Cpu size={18} />
Plugins
</button>
<button
class={`flex flex-col items-center gap-1 text-xs ${
props.tab === "mcp" ? "text-gray-12" : "text-gray-10"
}`}
onClick={() => props.setTab("mcp")}
>
<Server size={18} />
MCPs
<Box size={18} />
Apps
</button>
</div>
</nav>

View File

@@ -43,16 +43,16 @@ export type McpViewProps = {
const statusBadge = (status: "connected" | "needs_auth" | "needs_client_registration" | "failed" | "disabled" | "disconnected") => {
switch (status) {
case "connected":
return "bg-green-7/10 text-green-11 border-green-7/20";
return "bg-green-3 text-green-11 border-green-6";
case "needs_auth":
case "needs_client_registration":
return "bg-amber-7/10 text-amber-11 border-amber-7/20";
return "bg-amber-3 text-amber-11 border-amber-6";
case "disabled":
return "bg-gray-4/60 text-gray-11 border-gray-7/50";
return "bg-gray-3 text-gray-11 border-gray-6";
case "disconnected":
return "bg-gray-2/80 text-gray-12 border-gray-7/50";
return "bg-gray-2 text-gray-11 border-gray-6";
default:
return "bg-red-7/10 text-red-11 border-red-7/20";
return "bg-red-3 text-red-11 border-red-6";
}
};
@@ -180,41 +180,37 @@ export default function McpView(props: McpViewProps) {
const canConnect = () => !props.busy;
return (
<section class="space-y-6">
<div class="space-y-1">
<h2 class="text-lg font-semibold text-gray-12">{translate("mcp.title")}</h2>
<p class="text-sm text-gray-11">
{translate("mcp.description")}
</p>
</div>
<div class="grid gap-6 lg:grid-cols-[1.5fr_1fr] animate-in fade-in slide-in-from-top-11 duration-300">
<div class="space-y-6">
<div class="bg-gray-2/30 border border-gray-6/50 rounded-2xl p-5 space-y-4">
<div class="flex items-start justify-between gap-4">
<div>
<div class="text-sm font-medium text-gray-12">{translate("mcp.mcps_title")}</div>
<div class="text-xs text-gray-10">
{translate("mcp.connect_mcp_hint")}
</div>
</div>
<div class="text-xs text-gray-10 text-right">
<div>{props.mcpServers.length} {translate("mcp.configured")}</div>
<Show when={props.mcpLastUpdatedAt}>
<div>{translate("mcp.updated")} {formatRelativeTime(props.mcpLastUpdatedAt ?? Date.now())}</div>
</Show>
</div>
</div>
<Show when={props.mcpStatus}>
<div class="text-xs text-gray-10">{props.mcpStatus}</div>
<section class="space-y-10">
<div class="rounded-2xl border border-dls-border bg-dls-surface p-6 md:p-8 space-y-4">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 class="text-2xl font-bold text-dls-text">{translate("mcp.title")}</h2>
<p class="text-sm text-dls-secondary mt-1">
{translate("mcp.description")}
</p>
</div>
<div class="text-xs text-dls-secondary text-right">
<div>{props.mcpServers.length} {translate("mcp.configured")}</div>
<Show when={props.mcpLastUpdatedAt}>
<div>{translate("mcp.updated")} {formatRelativeTime(props.mcpLastUpdatedAt ?? Date.now())}</div>
</Show>
</div>
</div>
<Show when={props.mcpStatus}>
<div class="rounded-xl border border-dls-border bg-dls-hover px-4 py-3 text-xs text-dls-secondary">
{props.mcpStatus}
</div>
</Show>
</div>
<div class="grid gap-8 lg:grid-cols-[1.5fr_1fr] animate-in fade-in slide-in-from-top-11 duration-300">
<div class="space-y-6">
<Show when={props.showMcpReloadBanner}>
<div class="bg-gray-2/60 border border-gray-6/70 rounded-2xl px-4 py-3 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div class="bg-dls-hover border border-dls-border rounded-xl px-4 py-3 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<div class="text-sm font-medium text-gray-12">{translate("mcp.reload_banner_title")}</div>
<div class="text-xs text-gray-10">
<div class="text-sm font-semibold text-dls-text">{translate("mcp.reload_banner_title")}</div>
<div class="text-xs text-dls-secondary">
{props.reloadBlocked
? translate("mcp.reload_banner_description_blocked")
: translate("mcp.reload_banner_description")}
@@ -231,20 +227,20 @@ export default function McpView(props: McpViewProps) {
</div>
</Show>
<div class="bg-gray-2/30 border border-gray-6/50 rounded-2xl p-5 space-y-4">
<div class="rounded-2xl border border-dls-border bg-dls-surface p-6 space-y-4">
<div class="flex items-center justify-between">
<div class="text-sm font-medium text-gray-12">{translate("mcp.quick_connect_title")}</div>
<div class="text-[11px] text-gray-10">{translate("mcp.oauth_only_label")}</div>
<div class="text-sm font-semibold text-dls-text">{translate("mcp.quick_connect_title")}</div>
<div class="text-[11px] text-dls-secondary">{translate("mcp.oauth_only_label")}</div>
</div>
<div class="grid gap-3">
<For each={quickConnectList()}>
{(entry) => (
<div class="rounded-2xl border border-gray-6/70 bg-gray-1/40 p-4 space-y-3">
<div class="rounded-xl border border-dls-border bg-dls-hover p-4 space-y-3">
<div class="flex items-start justify-between gap-4">
<div>
<div class="text-sm font-medium text-gray-12">{entry.name}</div>
<div class="text-xs text-gray-10 mt-1">{entry.description}</div>
<div class="text-xs text-gray-7 font-mono mt-1">
<div class="text-sm font-semibold text-dls-text">{entry.name}</div>
<div class="text-xs text-dls-secondary mt-1">{entry.description}</div>
<div class="text-xs text-dls-secondary font-mono mt-1">
{entry.type === "local" ? entry.command?.join(" ") : entry.url}
</div>
</div>
@@ -252,7 +248,7 @@ export default function McpView(props: McpViewProps) {
<Show
when={!isQuickConnectConnected(entry.name)}
fallback={
<div class="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-green-7/10 border border-green-7/20">
<div class="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-green-3 border border-green-6">
<CheckCircle2 size={16} class="text-green-11" />
<span class="text-sm text-green-11">{translate("mcp.connected_status")}</span>
</div>
@@ -287,22 +283,22 @@ export default function McpView(props: McpViewProps) {
</Show>
</div>
</div>
<div class="text-[11px] text-gray-10">{translate("mcp.no_env_vars")}</div>
<div class="text-[11px] text-dls-secondary">{translate("mcp.no_env_vars")}</div>
</div>
)}
</For>
</div>
</div>
<div class="bg-gray-2/30 border border-gray-6/50 rounded-2xl p-5 space-y-4">
<div class="bg-dls-surface border border-dls-border rounded-2xl p-5 space-y-4">
<div class="flex items-center justify-between">
<div class="text-sm font-medium text-gray-12">{translate("mcp.connected_title")}</div>
<div class="text-[11px] text-gray-10">{translate("mcp.from_opencode_json")}</div>
<div class="text-sm font-medium text-dls-text">{translate("mcp.connected_title")}</div>
<div class="text-[11px] text-dls-secondary">{translate("mcp.from_opencode_json")}</div>
</div>
<Show
when={props.mcpServers.length}
fallback={
<div class="rounded-xl border border-gray-6/60 bg-gray-1/40 p-4 text-sm text-gray-10">
<div class="rounded-xl border border-dls-border bg-dls-hover p-4 text-sm text-dls-secondary">
{translate("mcp.no_servers_yet")}
</div>
}
@@ -320,17 +316,17 @@ export default function McpView(props: McpViewProps) {
return (
<button
type="button"
class={`text-left rounded-2xl border px-4 py-3 transition-all ${
class={`text-left rounded-2xl border px-4 py-3 transition-colors ${
props.selectedMcp === entry.name
? "border-gray-8 bg-gray-2/70"
: "border-gray-6/70 bg-gray-1/40 hover:border-gray-7"
? "border-dls-border bg-dls-active"
: "border-dls-border bg-dls-hover hover:bg-dls-active"
}`}
onClick={() => props.setSelectedMcp(entry.name)}
>
<div class="flex items-center justify-between gap-3">
<div>
<div class="text-sm font-medium text-gray-12">{entry.name}</div>
<div class="text-xs text-gray-10 font-mono">
<div class="text-sm font-medium text-dls-text">{entry.name}</div>
<div class="text-xs text-dls-secondary font-mono">
{entry.config.type === "remote" ? entry.config.url : entry.config.command?.join(" ")}
</div>
</div>
@@ -346,11 +342,11 @@ export default function McpView(props: McpViewProps) {
</Show>
</div>
<div class="bg-gray-2/30 border border-gray-6/50 rounded-2xl p-5 space-y-4">
<div class="bg-dls-surface border border-dls-border rounded-2xl p-5 space-y-4">
<div class="flex items-start justify-between gap-4">
<div class="space-y-1">
<div class="text-sm font-medium text-gray-12">{translate("mcp.edit_config_title")}</div>
<div class="text-xs text-gray-10">
<div class="text-sm font-medium text-dls-text">{translate("mcp.edit_config_title")}</div>
<div class="text-xs text-dls-secondary">
{translate("mcp.edit_config_description")}
</div>
</div>
@@ -358,7 +354,7 @@ export default function McpView(props: McpViewProps) {
href="https://opencode.ai/docs/mcp-servers/"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1.5 text-xs text-gray-10 hover:text-gray-12 underline decoration-gray-6/30 underline-offset-4 transition-colors"
class="inline-flex items-center gap-1.5 text-xs text-dls-secondary hover:text-dls-text underline decoration-dls-border underline-offset-4 transition-colors"
>
<ExternalLink size={12} />
{translate("mcp.docs_link")}
@@ -369,8 +365,8 @@ export default function McpView(props: McpViewProps) {
<button
class={`px-3 py-1 rounded-full text-xs font-medium border transition-colors ${
configScope() === "project"
? "bg-gray-12/10 text-gray-12 border-gray-6/30"
: "text-gray-10 border-gray-6 hover:text-gray-12"
? "bg-dls-active text-dls-text border-dls-border"
: "text-dls-secondary border-dls-border hover:text-dls-text hover:bg-dls-hover"
}`}
onClick={() => setConfigScope("project")}
>
@@ -379,8 +375,8 @@ export default function McpView(props: McpViewProps) {
<button
class={`px-3 py-1 rounded-full text-xs font-medium border transition-colors ${
configScope() === "global"
? "bg-gray-12/10 text-gray-12 border-gray-6/30"
: "text-gray-10 border-gray-6 hover:text-gray-12"
? "bg-dls-active text-dls-text border-dls-border"
: "text-dls-secondary border-dls-border hover:text-dls-text hover:bg-dls-hover"
}`}
onClick={() => setConfigScope("global")}
>
@@ -388,9 +384,9 @@ export default function McpView(props: McpViewProps) {
</button>
</div>
<div class="flex flex-col gap-1 text-xs text-gray-10">
<div class="flex flex-col gap-1 text-xs text-dls-secondary">
<div>{translate("mcp.config_label")}</div>
<div class="text-gray-7 font-mono truncate">
<div class="text-dls-secondary font-mono truncate">
{activeConfig()?.path ?? translate("mcp.config_not_loaded")}
</div>
</div>
@@ -415,38 +411,38 @@ export default function McpView(props: McpViewProps) {
</Show>
</Button>
<Show when={activeConfig() && activeConfig()!.exists === false}>
<div class="text-[11px] text-zinc-600">{translate("mcp.file_not_found")}</div>
<div class="text-[11px] text-dls-secondary">{translate("mcp.file_not_found")}</div>
</Show>
</div>
<Show when={configError()}>
<div class="text-xs text-red-300">{configError()}</div>
<div class="text-xs text-red-11">{configError()}</div>
</Show>
</div>
</div>
<div class="bg-gray-2/30 border border-gray-6/50 rounded-2xl p-5 space-y-4 lg:sticky lg:top-6 self-start">
<div class="bg-dls-surface border border-dls-border rounded-2xl p-5 space-y-4 lg:sticky lg:top-6 self-start">
<div class="flex items-center justify-between">
<div class="text-sm font-medium text-gray-12">{translate("mcp.details_title")}</div>
<div class="text-xs text-gray-10">{selectedEntry()?.name ?? translate("mcp.select_server_hint").split(" ").slice(0, 3).join(" ")}</div>
<div class="text-sm font-medium text-dls-text">{translate("mcp.details_title")}</div>
<div class="text-xs text-dls-secondary">{selectedEntry()?.name ?? translate("mcp.select_server_hint").split(" ").slice(0, 3).join(" ")}</div>
</div>
<Show
when={selectedEntry()}
fallback={
<div class="rounded-xl border border-gray-6/60 bg-gray-1/40 p-4 text-sm text-gray-10">
<div class="rounded-xl border border-dls-border bg-dls-hover p-4 text-sm text-dls-secondary">
{translate("mcp.select_server_hint")}
</div>
}
>
{(entry) => (
<div class="space-y-4">
<div class="rounded-xl border border-gray-6/70 bg-gray-1/40 p-4 space-y-2">
<div class="flex items-center gap-2 text-sm text-gray-12">
<div class="rounded-xl border border-dls-border bg-dls-hover p-4 space-y-2">
<div class="flex items-center gap-2 text-sm text-dls-text">
<Settings size={16} />
{entry().name}
</div>
<div class="text-xs text-gray-10 font-mono break-all">
<div class="text-xs text-dls-secondary font-mono break-all">
{entry().config.type === "remote" ? entry().config.url : entry().config.command?.join(" ")}
</div>
<div class="flex items-center gap-2">
@@ -467,28 +463,28 @@ export default function McpView(props: McpViewProps) {
</div>
</div>
<div class="rounded-xl border border-gray-6/70 bg-gray-1/40 p-4 space-y-2">
<div class="text-xs text-gray-11 uppercase tracking-wider">{translate("mcp.capabilities_label")}</div>
<div class="rounded-xl border border-dls-border bg-dls-hover p-4 space-y-2">
<div class="text-xs text-dls-secondary uppercase tracking-wider">{translate("mcp.capabilities_label")}</div>
<div class="flex flex-wrap gap-2">
<span class="text-[10px] uppercase tracking-wide bg-gray-4/70 text-gray-11 px-2 py-0.5 rounded-full">
<span class="text-[10px] uppercase tracking-wide bg-dls-active text-dls-secondary px-2 py-0.5 rounded-full">
{translate("mcp.tools_enabled_label")}
</span>
<span class="text-[10px] uppercase tracking-wide bg-gray-4/70 text-gray-11 px-2 py-0.5 rounded-full">
<span class="text-[10px] uppercase tracking-wide bg-dls-active text-dls-secondary px-2 py-0.5 rounded-full">
{translate("mcp.oauth_ready_label")}
</span>
</div>
<div class="text-xs text-gray-10">
<div class="text-xs text-dls-secondary">
{translate("mcp.usage_hint_text")}
</div>
</div>
<div class="rounded-xl border border-gray-6/70 bg-gray-1/40 p-4 space-y-2">
<div class="text-xs text-gray-11 uppercase tracking-wider">{translate("mcp.next_steps_label")}</div>
<div class="flex items-center gap-2 text-xs text-gray-10">
<div class="rounded-xl border border-dls-border bg-dls-hover p-4 space-y-2">
<div class="text-xs text-dls-secondary uppercase tracking-wider">{translate("mcp.next_steps_label")}</div>
<div class="flex items-center gap-2 text-xs text-dls-secondary">
<CheckCircle2 size={14} />
{translate("mcp.reload_step")}
</div>
<div class="flex items-center gap-2 text-xs text-gray-10">
<div class="flex items-center gap-2 text-xs text-dls-secondary">
<CircleAlert size={14} />
{translate("mcp.auth_step")}
</div>

View File

@@ -248,7 +248,7 @@ export default function OnboardingView(props: OnboardingViewProps) {
<div class="space-y-3">
<div class="flex gap-2">
<input
class="w-full bg-gray-2/50 border border-gray-6 rounded-xl px-3 py-2 text-sm text-gray-12 placeholder-gray-7 focus:outline-none focus:ring-1 focus:ring-gray-8 focus:border-gray-8 transition-all"
class="w-full bg-dls-surface border border-dls-border rounded-xl px-3 py-2 text-sm text-dls-text placeholder:text-dls-secondary focus:outline-none focus:ring-1 focus:ring-[rgba(var(--dls-accent-rgb),0.2)] focus:border-dls-accent transition-all"
placeholder={translate("onboarding.add_folder_path")}
value={props.newAuthorizedDir}
onInput={(e) => props.onSetAuthorizedDir(e.currentTarget.value)}

View File

@@ -95,7 +95,7 @@ const navItems: Array<{
label: string;
icon: any;
}> = [
{ id: "home", label: "Dashboard", icon: Command },
{ id: "scheduled", label: "Schedule", icon: Command },
{ id: "sessions", label: "Sessions", icon: Play },
{ id: "skills", label: "Skills", icon: Folder },
{ id: "settings", label: "Settings", icon: Settings },
@@ -224,7 +224,7 @@ export default function ProtoWorkspacesView() {
{(item) => (
<button
class={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-colors ${
item.id === "home"
item.id === "scheduled"
? "bg-gray-2 text-gray-12"
: "text-gray-10 hover:text-gray-12 hover:bg-gray-2/50"
}`}
@@ -277,7 +277,7 @@ export default function ProtoWorkspacesView() {
<div class="flex items-center gap-2 rounded-2xl border border-gray-6/60 bg-gray-2/50 px-4 py-3">
<input
placeholder="Draft a task to run..."
class="flex-1 bg-transparent border-none p-0 text-sm text-gray-12 placeholder-gray-7 focus:ring-0"
class="flex-1 bg-transparent border-none p-0 text-sm text-dls-text placeholder:text-dls-secondary focus:ring-0"
/>
<button class="rounded-xl bg-gray-12 px-3 py-1.5 text-xs font-semibold text-gray-1">Run</button>
</div>
@@ -436,7 +436,7 @@ export default function ProtoWorkspacesView() {
<div class="max-w-2xl mx-auto flex items-center gap-2 rounded-2xl border border-gray-6/60 bg-gray-2/40 px-4 py-3">
<input
placeholder="Describe a task..."
class="flex-1 bg-transparent text-sm text-gray-12 placeholder-gray-7 focus:outline-none"
class="flex-1 bg-transparent text-sm text-dls-text placeholder:text-dls-secondary focus:outline-none"
/>
<button class="rounded-xl bg-gray-12 px-3 py-1.5 text-xs font-semibold text-gray-1">Send</button>
</div>

View File

@@ -57,7 +57,7 @@ const statusTone = (status?: string | null) => {
if (status === "success") return "border-emerald-7/50 bg-emerald-4/20 text-emerald-11";
if (status === "failed") return "border-red-7/50 bg-red-4/20 text-red-11";
if (status === "running") return "border-amber-7/50 bg-amber-4/20 text-amber-11";
return "border-gray-6/60 bg-gray-2/40 text-gray-11";
return "border-dls-border bg-dls-hover text-dls-secondary";
};
export default function ScheduledTasksView(props: ScheduledTasksViewProps) {
@@ -120,87 +120,87 @@ export default function ScheduledTasksView(props: ScheduledTasksViewProps) {
};
return (
<section class="space-y-8">
<div class="bg-gradient-to-r from-gray-2 to-gray-4 rounded-3xl p-1">
<div class="bg-gray-1 rounded-[22px] p-6 md:p-8 space-y-6">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h3 class="text-lg font-semibold text-gray-12">Scheduled Tasks</h3>
<p class="text-sm text-gray-10 mt-1">
{sourceDescription()}
</p>
</div>
<Button
variant="secondary"
onClick={() => props.refreshJobs({ force: true })}
disabled={!supported() || props.busy}
>
<RefreshCw size={16} />
{props.busy ? "Refreshing" : "Refresh"}
</Button>
</div>
<section class="space-y-10">
<div class="flex flex-wrap items-center justify-end gap-4 border-b border-dls-border pb-4">
<button
type="button"
onClick={() => props.refreshJobs({ force: true })}
disabled={!supported() || props.busy}
class={`flex items-center gap-1.5 text-xs font-medium transition-colors ${
!supported() || props.busy
? "text-dls-secondary"
: "text-dls-secondary hover:text-dls-text"
}`}
>
<RefreshCw size={14} />
{props.busy ? "Refreshing" : "Refresh"}
</button>
</div>
<div class="grid gap-3 sm:grid-cols-3">
<div class="rounded-2xl border border-gray-6/60 bg-gray-2/40 p-4">
<div class="text-[11px] uppercase tracking-wider text-gray-10">
Scheduled Jobs
</div>
<div class="mt-2 text-2xl font-semibold text-gray-12">
{props.jobs.length}
</div>
<div class="text-xs text-gray-9 mt-1">Active schedules</div>
</div>
<div class="rounded-2xl border border-gray-6/60 bg-gray-2/40 p-4">
<div class="text-[11px] uppercase tracking-wider text-gray-10">
Last Sync
</div>
<div class="mt-2 text-lg font-semibold text-gray-12">
{supported() ? lastUpdatedLabel() : "Unavailable"}
</div>
<div class="text-xs text-gray-9 mt-1">{sourceLabel()}</div>
</div>
<div class="rounded-2xl border border-gray-6/60 bg-gray-2/40 p-4">
<div class="text-[11px] uppercase tracking-wider text-gray-10">Scheduler</div>
<div class="mt-2 text-lg font-semibold text-gray-12">
{supported() ? schedulerLabel() : "Unavailable"}
</div>
<div class="text-xs text-gray-9 mt-1">
{supported() ? schedulerHint() : schedulerUnavailableHint()}
</div>
</div>
<div class="space-y-2">
<h2 class="text-3xl font-bold text-dls-text">Automations</h2>
<p class="text-sm text-dls-secondary">{sourceDescription()}</p>
</div>
<div class="grid gap-4 sm:grid-cols-3">
<div class="rounded-xl border border-dls-border bg-dls-surface p-4">
<div class="text-[11px] font-bold uppercase tracking-widest text-dls-secondary">
Automations
</div>
<div class="mt-2 text-2xl font-semibold text-dls-text">
{props.jobs.length}
</div>
<div class="text-xs text-dls-secondary mt-1">Active automations</div>
</div>
<div class="rounded-xl border border-dls-border bg-dls-surface p-4">
<div class="text-[11px] font-bold uppercase tracking-widest text-dls-secondary">
Last Sync
</div>
<div class="mt-2 text-lg font-semibold text-dls-text">
{supported() ? lastUpdatedLabel() : "Unavailable"}
</div>
<div class="text-xs text-dls-secondary mt-1">{sourceLabel()}</div>
</div>
<div class="rounded-xl border border-dls-border bg-dls-surface p-4">
<div class="text-[11px] font-bold uppercase tracking-widest text-dls-secondary">Scheduler</div>
<div class="mt-2 text-lg font-semibold text-dls-text">
{supported() ? schedulerLabel() : "Unavailable"}
</div>
<div class="text-xs text-dls-secondary mt-1">
{supported() ? schedulerHint() : schedulerUnavailableHint()}
</div>
</div>
</div>
<Show when={supportNote()}>
<div class="rounded-2xl border border-gray-6/60 bg-gray-2/40 px-5 py-4 text-sm text-gray-10">
<div class="rounded-xl border border-dls-border bg-dls-hover px-5 py-4 text-sm text-dls-secondary">
{supportNote()}
</div>
</Show>
<Show when={props.status}>
<div class="rounded-2xl border border-red-7/40 bg-red-4/10 px-5 py-4 text-sm text-red-11">
<div class="rounded-xl border border-red-100 bg-red-50/40 px-5 py-4 text-sm text-red-600">
{props.status}
</div>
</Show>
<Show when={deleteError()}>
<div class="rounded-2xl border border-red-7/40 bg-red-4/10 px-5 py-4 text-sm text-red-11">
<div class="rounded-xl border border-red-100 bg-red-50/40 px-5 py-4 text-sm text-red-600">
{deleteError()}
</div>
</Show>
<div class="rounded-2xl border border-gray-6/60 bg-gray-1/40 overflow-hidden">
<div class="rounded-xl border border-dls-border bg-dls-surface overflow-hidden">
<Show
when={props.jobs.length}
fallback={
<div class="px-6 py-10 text-sm text-gray-10">
No scheduled tasks yet. Add the opencode-scheduler plugin and create a job to
see it here.
<div class="px-6 py-10 text-sm text-dls-secondary">
No automations yet. Add the opencode-scheduler plugin and create a job to see it
here.
</div>
}
>
<div class="divide-y divide-gray-6/60">
<div class="divide-y divide-dls-border">
<For each={props.jobs}>
{(job) => {
const summary = () => taskSummary(job);
@@ -209,13 +209,13 @@ export default function ScheduledTasksView(props: ScheduledTasksViewProps) {
<div class="flex flex-wrap items-start justify-between gap-4">
<div class="space-y-2">
<div class="flex items-center gap-2">
<Calendar size={16} class="text-gray-11" />
<div class="text-sm font-semibold text-gray-12">{job.name}</div>
<Calendar size={16} class="text-dls-secondary" />
<div class="text-sm font-semibold text-dls-text">{job.name}</div>
</div>
<div class="text-xs text-gray-10">
Cron <span class="font-mono text-gray-12">{job.schedule}</span>
<div class="text-xs text-dls-secondary">
Cron <span class="font-mono text-dls-text">{job.schedule}</span>
</div>
<div class="text-[11px] text-gray-7 font-mono">{job.slug}</div>
<div class="text-[11px] text-dls-secondary font-mono">{job.slug}</div>
</div>
<div class="flex items-center gap-2">
<span
@@ -238,43 +238,43 @@ export default function ScheduledTasksView(props: ScheduledTasksViewProps) {
</div>
<div class="grid gap-3 md:grid-cols-2">
<div class="rounded-xl border border-gray-6/60 bg-gray-2/30 p-4 space-y-2">
<div class="text-[10px] uppercase tracking-wide text-gray-10">
<div class="rounded-xl border border-dls-border bg-dls-hover p-4 space-y-2">
<div class="text-[10px] uppercase tracking-wide text-dls-secondary">
{summary().label}
</div>
<div
class={`text-sm text-gray-12 break-words ${
class={`text-sm text-dls-text break-words ${
summary().mono ? "font-mono" : ""
}`}
>
{summary().value}
</div>
</div>
<div class="rounded-xl border border-gray-6/60 bg-gray-2/30 p-4 space-y-2">
<div class="text-[10px] uppercase tracking-wide text-gray-10">Run context</div>
<div class="space-y-2 text-xs text-gray-10">
<div class="rounded-xl border border-dls-border bg-dls-hover p-4 space-y-2">
<div class="text-[10px] uppercase tracking-wide text-dls-secondary">Run context</div>
<div class="space-y-2 text-xs text-dls-secondary">
<div class="flex items-center gap-2">
<FolderOpen size={14} class="text-gray-9" />
<span class="font-mono text-gray-12 break-all">
<FolderOpen size={14} class="text-dls-secondary" />
<span class="font-mono text-dls-text break-all">
{job.workdir ?? "Default"}
</span>
</div>
<Show when={job.run?.attachUrl ?? job.attachUrl}>
<div class="flex items-center gap-2">
<Terminal size={14} class="text-gray-9" />
<span class="font-mono text-gray-12 break-all">
<Terminal size={14} class="text-dls-secondary" />
<span class="font-mono text-dls-text break-all">
{job.run?.attachUrl ?? job.attachUrl}
</span>
</div>
</Show>
<Show when={job.source}>
<div class="text-[11px] text-gray-9">Source: {job.source}</div>
<div class="text-[11px] text-dls-secondary">Source: {job.source}</div>
</Show>
</div>
</div>
</div>
<div class="flex flex-wrap gap-4 text-xs text-gray-10">
<div class="flex flex-wrap gap-4 text-xs text-dls-secondary">
<div class="flex items-center gap-1">
<Clock size={12} />
Last run {toRelative(job.lastRunAt)}
@@ -296,18 +296,18 @@ export default function ScheduledTasksView(props: ScheduledTasksViewProps) {
</div>
<Show when={deleteTarget()}>
<div class="fixed inset-0 z-50 bg-gray-1/60 backdrop-blur-sm flex items-center justify-center p-4">
<div class="bg-gray-2 border border-gray-6/70 w-full max-w-md rounded-2xl shadow-2xl overflow-hidden">
<div class="fixed inset-0 z-50 bg-black/20 backdrop-blur-sm flex items-center justify-center p-4">
<div class="bg-dls-surface border border-dls-border w-full max-w-md rounded-2xl shadow-2xl overflow-hidden">
<div class="p-6 space-y-4">
<div class="flex items-start justify-between gap-4">
<div>
<h3 class="text-lg font-semibold text-gray-12">Delete scheduled task?</h3>
<p class="text-sm text-gray-11 mt-1">
<h3 class="text-lg font-semibold text-dls-text">Delete scheduled task?</h3>
<p class="text-sm text-dls-secondary mt-1">
{deleteDescription()}
</p>
</div>
</div>
<div class="rounded-xl bg-gray-1/20 border border-gray-6 p-3 text-xs text-gray-11 font-mono break-all">
<div class="rounded-xl bg-dls-hover border border-dls-border p-3 text-xs text-dls-secondary font-mono break-all">
{deleteTarget()?.name}
</div>
<div class="flex justify-end gap-2">

View File

@@ -21,7 +21,20 @@ import type {
import type { WorkspaceInfo } from "../lib/tauri";
import { ArrowRight, ChevronDown, HardDrive, Shield, Zap } from "lucide-solid";
import {
Box,
ChevronDown,
ChevronRight,
Edit2,
HardDrive,
History,
Layout,
MoreHorizontal,
Plus,
Settings,
Shield,
Zap,
} from "lucide-solid";
import Button from "../components/button";
import RenameSessionModal from "../components/rename-session-modal";
@@ -29,12 +42,11 @@ import ProviderAuthModal from "../components/provider-auth-modal";
import StatusBar from "../components/status-bar";
import type { OpenworkServerStatus } from "../lib/openwork-server";
import { join } from "@tauri-apps/api/path";
import { isTauriRuntime } from "../utils";
import { formatRelativeTime, isTauriRuntime } from "../utils";
import MessageList from "../components/session/message-list";
import Composer from "../components/session/composer";
import SessionSidebar, { type SidebarSectionState } from "../components/session/sidebar";
import ContextPanel from "../components/session/context-panel";
import type { SidebarSectionState } from "../components/session/sidebar";
import FlyoutItem from "../components/flyout-item";
import QuestionModal from "../components/question-modal";
@@ -65,7 +77,13 @@ export type SessionViewProps = {
createSessionAndOpen: () => void;
sendPromptAsync: (draft: ComposerDraft) => Promise<void>;
newTaskDisabled: boolean;
sessions: Array<{ id: string; title: string; slug?: string | null; workspaceLabel?: string | null }>;
sessions: Array<{
id: string;
title: string;
slug?: string | null;
workspaceLabel?: string | null;
time?: { updated: number };
}>;
selectSession: (sessionId: string) => Promise<void> | void;
messages: MessageWithParts[];
todos: TodoItem[];
@@ -130,35 +148,6 @@ export type SessionViewProps = {
deleteSession: (sessionId: string) => Promise<void>;
};
type SessionSummary = { id: string; title: string; slug?: string | null };
const WORKSPACE_ORDER_KEY = "openwork.workspace-order.v1";
const readWorkspaceOrder = (): string[] => {
if (typeof window === "undefined") return [];
try {
const raw = window.localStorage.getItem(WORKSPACE_ORDER_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter((entry): entry is string => typeof entry === "string");
} catch {
return [];
}
};
const writeWorkspaceOrder = (order: string[]) => {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(WORKSPACE_ORDER_KEY, JSON.stringify(order));
} catch {
// ignore
}
};
const arraysEqual = (a: string[], b: string[]) =>
a.length === b.length && a.every((value, index) => value === b[index]);
export default function SessionView(props: SessionViewProps) {
let messagesEndEl: HTMLDivElement | undefined;
let chatContainerEl: HTMLDivElement | undefined;
@@ -169,19 +158,35 @@ export default function SessionView(props: SessionViewProps) {
const [renameModalOpen, setRenameModalOpen] = createSignal(false);
const [renameTitle, setRenameTitle] = createSignal("");
const [renameBusy, setRenameBusy] = createSignal(false);
const [deleteBusy, setDeleteBusy] = createSignal(false);
const [agentPickerOpen, setAgentPickerOpen] = createSignal(false);
const [agentPickerBusy, setAgentPickerBusy] = createSignal(false);
const [agentPickerReady, setAgentPickerReady] = createSignal(false);
const [agentPickerError, setAgentPickerError] = createSignal<string | null>(null);
const [workspaceOrder, setWorkspaceOrder] = createSignal<string[]>(readWorkspaceOrder());
const [sessionsByWorkspaceId, setSessionsByWorkspaceId] = createSignal<Record<string, SessionSummary[]>>({});
const [agentOptions, setAgentOptions] = createSignal<Agent[]>([]);
const [autoScrollEnabled, setAutoScrollEnabled] = createSignal(false);
const [scrollOnNextUpdate, setScrollOnNextUpdate] = createSignal(false);
const [unreadCount, setUnreadCount] = createSignal(0);
const agentLabel = createMemo(() => props.selectedSessionAgent ?? "Default agent");
const workspaceStatus = createMemo(() => {
switch (props.openworkServerStatus) {
case "connected":
return { label: "Live", className: "bg-emerald-3 text-emerald-11" };
case "limited":
return { label: "Limited", className: "bg-amber-3 text-amber-11" };
case "disconnected":
default:
return { label: "Offline", className: "bg-red-3 text-red-11" };
}
});
const workspaceTypeLabel = createMemo(() =>
props.activeWorkspaceDisplay.workspaceType === "remote" ? "Remote" : "Local"
);
const [sessionsExpanded, setSessionsExpanded] = createSignal(true);
const [showAllSessions, setShowAllSessions] = createSignal(false);
const visibleSessions = createMemo(() =>
showAllSessions() ? props.sessions : props.sessions.slice(0, 5)
);
const attachmentsEnabled = createMemo(() => {
if (props.activeWorkspaceDisplay.workspaceType !== "remote") return true;
return props.openworkServerStatus === "connected";
@@ -606,57 +611,6 @@ export default function SessionView(props: SessionViewProps) {
return props.sessions.find((session) => session.id === id)?.title ?? "";
});
createEffect(() => {
const ids = props.workspaces.map((workspace) => workspace.id);
const base = workspaceOrder().length ? workspaceOrder() : readWorkspaceOrder();
const filtered = base.filter((id) => ids.includes(id));
const missing = ids.filter((id) => !filtered.includes(id));
const next = [...filtered, ...missing];
if (!arraysEqual(base, next)) {
writeWorkspaceOrder(next);
}
if (!arraysEqual(workspaceOrder(), next)) {
setWorkspaceOrder(next);
}
});
const orderedWorkspaces = createMemo(() => {
const byId = new Map(props.workspaces.map((workspace) => [workspace.id, workspace]));
const order = workspaceOrder();
const list = order.map((id) => byId.get(id)).filter((workspace): workspace is WorkspaceInfo => Boolean(workspace));
return list.length ? list : props.workspaces;
});
createEffect(() => {
const workspaceId = props.activeWorkspaceId;
if (!workspaceId) return;
const list = props.sessions.map((session) => ({
id: session.id,
title: session.title,
slug: session.slug,
}));
setSessionsByWorkspaceId((prev) => ({
...prev,
[workspaceId]: list,
}));
});
const sessionWorkspaceGroups = createMemo(() => {
const byWorkspace = sessionsByWorkspaceId();
return orderedWorkspaces().map((workspace) => ({
workspace,
sessions: byWorkspace[workspace.id] ?? [],
}));
});
const pickFallbackSessionId = (targetId: string) => {
const list = props.sessions.map((session) => session.id);
if (list.length <= 1) return null;
const index = list.indexOf(targetId);
if (index === -1) return list[0] ?? null;
return list[index + 1] ?? list[index - 1] ?? null;
};
const renameCanSave = createMemo(() => {
if (renameBusy()) return false;
const next = renameTitle().trim();
@@ -695,38 +649,6 @@ export default function SessionView(props: SessionViewProps) {
}
};
const handleDeleteSession = async (sessionId: string) => {
if (deleteBusy()) return;
const targetId = sessionId?.trim();
if (!targetId) {
setToastMessage("No session selected");
return;
}
const targetTitle = props.sessions.find((session) => session.id === targetId)?.title ?? "this session";
const confirmed = window.confirm(`Delete session "${targetTitle}"?`);
if (!confirmed) return;
const fallbackId = pickFallbackSessionId(targetId);
setDeleteBusy(true);
try {
await props.deleteSession(targetId);
setToastMessage("Session deleted");
if (props.selectedSessionId !== targetId) return;
if (fallbackId) {
await Promise.resolve(props.selectSession(fallbackId));
props.setView("session", fallbackId);
props.setTab("sessions");
return;
}
props.setView("dashboard");
props.setTab("sessions");
} catch (error) {
const message = error instanceof Error ? error.message : props.safeStringify(error);
setToastMessage(message);
} finally {
setDeleteBusy(false);
}
};
const requireSessionId = () => {
const sessionId = props.selectedSessionId;
if (!sessionId) {
@@ -801,6 +723,12 @@ export default function SessionView(props: SessionViewProps) {
props.setPrompt(draft.text);
};
const openSessionFromList = (sessionId: string) => {
if (!sessionId) return;
void props.selectSession(sessionId);
props.setView("session", sessionId);
};
const openSettings = (tab: SettingsTab = "general") => {
props.setSettingsTab(tab);
props.setTab("settings");
@@ -812,37 +740,6 @@ export default function SessionView(props: SessionViewProps) {
props.setView("dashboard");
};
const handleSelectSession = async (workspaceId: string, sessionId: string) => {
const targetWorkspaceId = workspaceId?.trim();
if (!targetWorkspaceId || !sessionId) return;
if (props.connectingWorkspaceId && props.connectingWorkspaceId !== targetWorkspaceId) return;
if (targetWorkspaceId !== props.activeWorkspaceId) {
const result = await props.activateWorkspace(targetWorkspaceId);
if (result === false) return;
}
await props.selectSession(sessionId);
props.setView("session", sessionId);
props.setTab("sessions");
};
const handleReorderWorkspace = (fromId: string, toId: string | null) => {
setWorkspaceOrder((current) => {
const base = current.length ? current : props.workspaces.map((workspace) => workspace.id);
if (!base.includes(fromId)) return current;
const next = base.filter((id) => id !== fromId);
if (toId) {
const index = next.indexOf(toId);
if (index === -1) return current;
next.splice(index, 0, fromId);
} else {
next.push(fromId);
}
if (arraysEqual(base, next)) return current;
writeWorkspaceOrder(next);
return next;
});
};
const openProviderAuth = () => {
void props.openProviderAuthModal().catch((error) => {
const message = error instanceof Error ? error.message : "Connect failed";
@@ -857,33 +754,225 @@ export default function SessionView(props: SessionViewProps) {
};
return (
<div class="h-screen flex flex-col bg-gray-1 text-gray-12 relative pb-16 md:pb-12">
<header class="h-16 border-b border-gray-6 flex items-center justify-between px-6 bg-gray-1/80 backdrop-blur-md z-10 sticky top-0">
<div class="flex items-center gap-3">
<Button
variant="ghost"
class="!p-2 rounded-full md:!px-3 md:!py-2 md:rounded-xl"
<div class="flex h-screen w-full bg-dls-surface text-dls-text font-sans overflow-hidden">
<aside class="w-64 hidden md:flex flex-col bg-dls-sidebar border-r border-dls-border p-4">
<div class="space-y-0.5 mb-6 pt-2">
<button
type="button"
class={`w-full h-10 flex items-center gap-3 px-3 rounded-lg text-sm font-medium transition-colors ${
props.tab === "scheduled"
? "bg-dls-active text-dls-text"
: "text-dls-secondary hover:text-dls-text hover:bg-dls-hover"
}`}
onClick={() => {
props.setTab("sessions");
props.setTab("scheduled");
props.setView("dashboard");
}}
title="Back to dashboard"
>
<ArrowRight class="rotate-180 w-5 h-5" />
<span class="hidden md:inline text-xs">Back</span>
</Button>
<div class="px-3 py-1.5 rounded-xl bg-gray-2 text-xs text-gray-11 font-medium">
{props.activeWorkspaceDisplay.name}
</div>
<Show when={props.developerMode}>
<span class="text-xs text-gray-7">{props.headerStatus}</span>
</Show>
<Show when={props.busyHint}>
<span class="text-xs text-gray-10">· {props.busyHint}</span>
</Show>
<History size={18} />
Automations
</button>
<button
type="button"
class={`w-full h-10 flex items-center gap-3 px-3 rounded-lg text-sm font-medium transition-colors ${
props.tab === "skills"
? "bg-dls-active text-dls-text"
: "text-dls-secondary hover:text-dls-text hover:bg-dls-hover"
}`}
onClick={() => {
props.setTab("skills");
props.setView("dashboard");
}}
>
<Zap size={18} />
Skills
</button>
<button
type="button"
class={`w-full h-10 flex items-center gap-3 px-3 rounded-lg text-sm font-medium transition-colors ${
props.tab === "mcp"
? "bg-dls-active text-dls-text"
: "text-dls-secondary hover:text-dls-text hover:bg-dls-hover"
}`}
onClick={() => {
props.setTab("mcp");
props.setView("dashboard");
}}
>
<Box size={18} />
Apps
</button>
</div>
</header>
<div class="space-y-2 mb-6">
<div class="flex items-center justify-between text-[11px] font-bold text-dls-secondary uppercase px-3 tracking-tight">
<span>Workspace</span>
<button
type="button"
aria-label="Workspace settings"
onClick={() => openSettings("general")}
class="text-dls-secondary hover:text-dls-text"
>
<Settings size={14} />
</button>
</div>
<div class="rounded-lg border border-dls-border bg-dls-surface px-3 py-2">
<div class="text-sm font-semibold text-dls-text truncate">
{props.activeWorkspaceDisplay.name}
</div>
<div class="mt-1 flex items-center gap-2 text-xs text-dls-secondary">
<span>{workspaceTypeLabel()}</span>
<span>{workspaceStatus().label}</span>
</div>
</div>
</div>
<div class="flex-1 overflow-y-auto">
<div class="flex items-center justify-between text-[11px] font-bold text-dls-secondary uppercase px-3 mb-3 tracking-tight">
<span>Sessions</span>
<div class="flex gap-2 text-dls-secondary">
<button type="button" class="hover:text-dls-text" aria-label="Session layout">
<Layout size={14} />
</button>
<button
type="button"
class="hover:text-dls-text"
aria-label="New session"
onClick={props.createSessionAndOpen}
disabled={props.newTaskDisabled}
>
<Plus size={14} />
</button>
</div>
</div>
<div class="mb-2">
<div
role="button"
tabIndex={0}
onClick={() => setSessionsExpanded((current) => !current)}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
setSessionsExpanded((current) => !current);
}}
class="group flex items-center justify-between h-8 px-3 rounded-lg cursor-pointer text-dls-text transition-colors hover:bg-dls-hover"
>
<div class="flex items-center gap-2">
<ChevronRight
size={14}
class={`text-dls-secondary transition-transform ${
sessionsExpanded() ? "rotate-90" : ""
}`}
/>
<span class="text-sm font-medium">{props.activeWorkspaceDisplay.name}</span>
</div>
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
class="p-1 hover:bg-dls-active rounded-md text-dls-secondary transition-colors"
onClick={(event) => event.stopPropagation()}
aria-label="Workspace options"
>
<MoreHorizontal size={14} />
</button>
</div>
</div>
<Show when={sessionsExpanded()}>
<div class="mt-0.5 space-y-0.5 border-l border-dls-border ml-4">
<Show
when={props.sessions.length > 0}
fallback={
<div class="px-3 py-2 text-xs text-dls-secondary">
No sessions yet.
</div>
}
>
<For each={visibleSessions()}>
{(session) => (
<div
role="button"
tabIndex={0}
class="group flex items-center justify-between h-8 px-3 hover:bg-dls-hover rounded-lg cursor-pointer relative overflow-hidden ml-5 w-[calc(100%-1.25rem)]"
onClick={() => openSessionFromList(session.id)}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
openSessionFromList(session.id);
}}
>
<span class="text-sm text-dls-text truncate mr-2 font-medium group-hover:pr-14 transition-all">
{session.title}
</span>
<Show when={session.time?.updated}>
<span class="text-xs text-dls-secondary whitespace-nowrap group-hover:opacity-0 transition-opacity">
{formatRelativeTime(session.time?.updated ?? Date.now())}
</span>
</Show>
<div class="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
class="p-1 hover:bg-dls-active rounded-md text-dls-text transition-colors"
onClick={(event) => event.stopPropagation()}
aria-label="Session options"
>
<MoreHorizontal size={14} />
</button>
<button
type="button"
class="p-1 hover:bg-dls-active rounded-md text-dls-text transition-colors"
onClick={(event) => event.stopPropagation()}
aria-label="Rename session"
>
<Edit2 size={14} />
</button>
</div>
</div>
)}
</For>
</Show>
</div>
</Show>
</div>
<Show when={props.sessions.length > 5}>
<button
type="button"
onClick={() => setShowAllSessions((current) => !current)}
class="px-3 py-1.5 text-xs text-dls-secondary hover:text-dls-text font-medium"
>
{showAllSessions() ? "Show less" : "Show more"}
</button>
</Show>
</div>
<div class="pt-4 border-t border-dls-border">
<button
type="button"
onClick={() => openSettings("general")}
class="flex items-center gap-3 px-3 py-2 rounded-lg text-dls-secondary hover:bg-dls-hover transition-colors"
>
<Settings size={18} />
<span class="text-sm font-medium">Settings</span>
</button>
</div>
</aside>
<main class="flex-1 flex flex-col relative pb-16 md:pb-12 bg-dls-surface">
<header class="h-14 border-b border-dls-border flex items-center justify-between px-6 bg-dls-surface z-10 sticky top-0">
<div class="flex items-center gap-3">
<h1 class="text-sm font-semibold text-dls-text">
{selectedSessionTitle() || "New thread"}
</h1>
<Show when={props.developerMode}>
<span class="text-xs text-dls-secondary">{props.headerStatus}</span>
</Show>
<Show when={props.busyHint}>
<span class="text-xs text-dls-secondary">· {props.busyHint}</span>
</Show>
</div>
</header>
<Show when={props.error}>
<div class="mx-auto max-w-5xl w-full px-6 md:px-10 pt-4">
@@ -894,47 +983,19 @@ export default function SessionView(props: SessionViewProps) {
</Show>
<div class="flex-1 flex overflow-hidden">
<aside class="hidden lg:flex w-72 border-r border-gray-6 bg-gray-1 flex-col">
<SessionSidebar
todos={props.todos}
expandedSections={props.expandedSidebarSections}
onToggleSection={(section) => {
props.setExpandedSidebarSections((curr) => ({ ...curr, [section]: !curr[section] }));
}}
workspaceGroups={sessionWorkspaceGroups()}
activeWorkspaceId={props.activeWorkspaceId}
connectingWorkspaceId={props.connectingWorkspaceId}
workspaceConnectionStateById={props.workspaceConnectionStateById}
onSelectWorkspace={props.activateWorkspace}
onCreateWorkspace={props.openCreateWorkspace}
onCreateRemoteWorkspace={props.openCreateRemoteWorkspace}
onImportWorkspace={props.importWorkspaceConfig}
importingWorkspaceConfig={props.importingWorkspaceConfig}
onEditWorkspace={props.editWorkspaceConnection}
onTestWorkspaceConnection={props.testWorkspaceConnection}
onForgetWorkspace={props.forgetWorkspace}
onReorderWorkspace={handleReorderWorkspace}
onSelectSession={handleSelectSession}
selectedSessionId={props.selectedSessionId}
sessionStatusById={props.sessionStatusById}
onCreateSession={props.createSessionAndOpen}
onDeleteSession={handleDeleteSession}
newTaskDisabled={props.newTaskDisabled}
/>
</aside>
<div
class="flex-1 overflow-y-auto pt-6 md:pt-10 scroll-smooth relative"
class="flex-1 overflow-y-auto px-12 py-10 scroll-smooth relative bg-dls-surface"
ref={(el) => (chatContainerEl = el)}
>
<div class="max-w-5xl mx-auto w-full">
<Show when={props.messages.length === 0}>
<div class="text-center py-16 px-6 space-y-6">
<div class="w-16 h-16 bg-gray-2 rounded-3xl mx-auto flex items-center justify-center border border-gray-6">
<Zap class="text-gray-7" />
<div class="w-16 h-16 bg-dls-hover rounded-3xl mx-auto flex items-center justify-center border border-dls-border">
<Zap class="text-dls-secondary" />
</div>
<div class="space-y-2">
<h3 class="text-xl font-medium">What do you want to do?</h3>
<p class="text-gray-10 text-sm max-w-sm mx-auto">
<p class="text-dls-secondary text-sm max-w-sm mx-auto">
Pick a starting point or just type below.
</p>
</div>
@@ -1054,30 +1115,9 @@ export default function SessionView(props: SessionViewProps) {
</Show>
<div ref={(el) => (messagesEndEl = el)} />
</div>
</div>
<aside class="hidden lg:flex w-72 border-l border-gray-6 bg-gray-1 flex-col">
<ContextPanel
activePlugins={props.activePlugins}
activePluginStatus={props.activePluginStatus}
mcpServers={props.mcpServers}
mcpStatuses={props.mcpStatuses}
mcpStatus={props.mcpStatus}
skills={props.skills}
skillsStatus={props.skillsStatus}
authorizedDirs={props.authorizedDirs}
workingFiles={props.workingFiles}
workspaceRoot={props.activeWorkspaceRoot}
expandedSections={props.expandedSidebarSections}
onToggleSection={(section) =>
props.setExpandedSidebarSections((curr) => ({
...curr,
[section]: !curr[section],
}))
}
onFileClick={handleWorkingFileClick}
/>
</aside>
</div>
<Composer
@@ -1133,6 +1173,8 @@ export default function SessionView(props: SessionViewProps) {
</div>
</Show>
</main>
<div class="fixed bottom-0 left-0 right-0">
<StatusBar
clientConnected={props.clientConnected}

View File

@@ -585,7 +585,7 @@ function OwpenbotSettings(props: {
}
>
<div class="relative">
<div class="flex justify-center p-4 bg-white rounded-lg">
<div class="flex justify-center p-4 bg-dls-surface rounded-lg">
<img
src={`data:image/png;base64,${qrCode()}`}
alt="WhatsApp QR Code"

View File

@@ -3,7 +3,7 @@ import { For, Show, createMemo, createSignal } from "solid-js";
import type { SkillCard } from "../types";
import Button from "../components/button";
import { FolderOpen, Package, Upload } from "lucide-solid";
import { Edit2, FolderOpen, Package, Plus, RefreshCw, Search, Sparkles, Upload } from "lucide-solid";
import { currentLocale, t } from "../../i18n";
export type SkillsViewProps = {
@@ -30,138 +30,170 @@ export default function SkillsView(props: SkillsViewProps) {
const [uninstallTarget, setUninstallTarget] = createSignal<SkillCard | null>(null);
const uninstallOpen = createMemo(() => uninstallTarget() != null);
const [searchQuery, setSearchQuery] = createSignal("");
const filteredSkills = createMemo(() => {
const query = searchQuery().trim().toLowerCase();
if (!query) return props.skills;
return props.skills.filter((skill) => {
const description = skill.description ?? "";
return (
skill.name.toLowerCase().includes(query) ||
description.toLowerCase().includes(query)
);
});
});
const recommendedSkills = createMemo(() => [
{
id: "skill-creator",
title: translate("skills.install_skill_creator"),
description: translate("skills.install_skill_creator_hint"),
icon: Sparkles,
onClick: () => props.installSkillCreator(),
disabled: props.busy || skillCreatorInstalled() || !props.canInstallSkillCreator,
},
{
id: "import-local",
title: translate("skills.import_local"),
description: translate("skills.import_local_hint"),
icon: Upload,
onClick: props.importLocalSkill,
disabled: props.busy || !props.canUseDesktopTools,
},
{
id: "reveal-folder",
title: translate("skills.reveal_folder"),
description: translate("skills.reveal_folder_hint"),
icon: FolderOpen,
onClick: props.revealSkillsFolder,
disabled: props.busy || !props.canUseDesktopTools,
},
]);
const handleNewSkill = () => {
if (props.busy) return;
if (props.canInstallSkillCreator && !skillCreatorInstalled()) {
props.installSkillCreator();
return;
}
if (props.canUseDesktopTools) {
props.revealSkillsFolder();
}
};
const newSkillDisabled = createMemo(
() =>
props.busy ||
(!props.canUseDesktopTools &&
(!props.canInstallSkillCreator || skillCreatorInstalled()))
);
return (
<section class="space-y-8">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h3 class="text-lg font-semibold text-gray-12">{translate("skills.title")}</h3>
<p class="text-sm text-gray-10 mt-1">{translate("skills.subtitle")}</p>
</div>
<Button variant="secondary" onClick={() => props.refreshSkills({ force: true })} disabled={props.busy}>
<section class="space-y-10">
<div class="flex flex-wrap items-center justify-end gap-4 border-b border-dls-border pb-4">
<button
type="button"
onClick={() => props.refreshSkills({ force: true })}
disabled={props.busy}
class={`flex items-center gap-1.5 text-xs font-medium transition-colors ${
props.busy
? "text-dls-secondary"
: "text-dls-secondary hover:text-dls-text"
}`}
>
<RefreshCw size={14} />
{translate("skills.refresh")}
</Button>
</button>
<div class="relative">
<Search size={14} class="absolute left-3 top-1/2 -translate-y-1/2 text-dls-secondary" />
<input
type="text"
value={searchQuery()}
onInput={(event) => setSearchQuery(event.currentTarget.value)}
placeholder="Search skills"
class="bg-dls-hover border border-dls-border rounded-lg py-1.5 pl-9 pr-4 text-xs w-48 focus:w-64 focus:outline-none transition-all"
/>
</div>
<button
type="button"
onClick={handleNewSkill}
disabled={newSkillDisabled()}
class={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${
newSkillDisabled()
? "bg-dls-active text-dls-secondary"
: "bg-dls-text text-dls-surface hover:opacity-90"
}`}
>
<Plus size={14} />
New skill
</button>
</div>
<div class="rounded-2xl border border-gray-6/60 bg-gray-1/40 overflow-hidden">
<div class="flex flex-wrap items-center justify-between gap-3 px-5 py-4 border-b border-gray-6/60 bg-gray-2/40">
<div>
<div class="text-xs font-semibold text-gray-11 uppercase tracking-wider">{translate("skills.add_title")}</div>
<div class="text-sm text-gray-10 mt-2">{translate("skills.add_description")}</div>
</div>
<Show when={props.accessHint}>
<div class="text-xs text-gray-10">{props.accessHint}</div>
</Show>
<Show
when={
!props.accessHint &&
!props.canInstallSkillCreator &&
!props.canUseDesktopTools
}
>
<div class="text-xs text-gray-10">{translate("skills.host_mode_only")}</div>
</Show>
</div>
<div class="divide-y divide-gray-6/60">
<div class="flex flex-wrap items-center justify-between gap-3 px-5 py-4">
<div>
<div class="text-sm font-medium text-gray-12">{translate("skills.install_skill_creator")}</div>
<div class="text-xs text-gray-10 mt-1">{translate("skills.install_skill_creator_hint")}</div>
</div>
<Button
variant={skillCreatorInstalled() ? "outline" : "secondary"}
onClick={() => {
if (skillCreatorInstalled()) return;
props.installSkillCreator();
}}
disabled={props.busy || skillCreatorInstalled() || !props.canInstallSkillCreator}
>
<Package size={16} />
{skillCreatorInstalled() ? translate("skills.installed_label") : translate("skills.install")}
</Button>
</div>
<div class="flex flex-wrap items-center justify-between gap-3 px-5 py-4">
<div>
<div class="text-sm font-medium text-gray-12">{translate("skills.import_local")}</div>
<div class="text-xs text-gray-10 mt-1">{translate("skills.import_local_hint")}</div>
</div>
<Button
variant="secondary"
onClick={props.importLocalSkill}
disabled={props.busy || !props.canUseDesktopTools}
>
<Upload size={16} />
{translate("skills.import")}
</Button>
</div>
<div class="flex flex-wrap items-center justify-between gap-3 px-5 py-4">
<div>
<div class="text-sm font-medium text-gray-12">{translate("skills.reveal_folder")}</div>
<div class="text-xs text-gray-10 mt-1">{translate("skills.reveal_folder_hint")}</div>
</div>
<Button
variant="secondary"
onClick={props.revealSkillsFolder}
disabled={props.busy || !props.canUseDesktopTools}
>
<FolderOpen size={16} />
{translate("skills.reveal_button")}
</Button>
</div>
</div>
<Show when={props.skillsStatus}>
<div class="border-t border-gray-6/60 px-5 py-3 text-xs text-gray-11 whitespace-pre-wrap break-words">
{props.skillsStatus}
</div>
<div class="space-y-2">
<h2 class="text-3xl font-bold text-dls-text">{translate("skills.title")}</h2>
<p class="text-sm text-dls-secondary">
{translate("skills.subtitle")} {" "}
<button type="button" class="text-dls-accent hover:underline">
Learn more
</button>
</p>
<Show when={props.accessHint}>
<div class="text-xs text-dls-secondary">{props.accessHint}</div>
</Show>
<Show
when={!props.accessHint && !props.canInstallSkillCreator && !props.canUseDesktopTools}
>
<div class="text-xs text-dls-secondary">{translate("skills.host_mode_only")}</div>
</Show>
</div>
<div>
<div class="flex items-center justify-between mb-3">
<div>
<div class="text-sm font-semibold text-gray-12">{translate("skills.installed")}</div>
<div class="text-xs text-gray-10 mt-1">{translate("skills.installed_description")}</div>
</div>
<div class="text-xs text-gray-10">{props.skills.length}</div>
<Show when={props.skillsStatus}>
<div class="rounded-xl border border-dls-border bg-dls-hover px-4 py-3 text-xs text-dls-secondary whitespace-pre-wrap break-words">
{props.skillsStatus}
</div>
</Show>
<div class="space-y-4">
<h3 class="text-[11px] font-bold text-dls-secondary uppercase tracking-widest">
{translate("skills.installed")}
</h3>
<Show
when={props.skills.length}
when={filteredSkills().length}
fallback={
<div class="rounded-2xl border border-gray-6/60 bg-gray-1/40 px-5 py-6 text-sm text-zinc-500">
<div class="rounded-xl border border-dls-border bg-dls-surface px-5 py-6 text-sm text-dls-secondary">
{translate("skills.no_skills")}
</div>
}
>
<div class="rounded-2xl border border-gray-6/60 bg-gray-1/40 divide-y divide-gray-6/60">
<For each={props.skills}>
{(s) => (
<div class="px-5 py-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="space-y-2">
<div class="flex items-center gap-2">
<Package size={16} class="text-gray-11" />
<div class="font-medium text-gray-12">{s.name}</div>
</div>
<Show when={s.description}>
<div class="text-sm text-gray-10">{s.description}</div>
</Show>
<div class="text-xs text-gray-7 font-mono">{s.path}</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<For each={filteredSkills()}>
{(skill) => (
<div class="bg-dls-surface border border-dls-border rounded-xl p-4 flex items-start justify-between group hover:border-dls-border transition-all">
<div class="flex gap-4">
<div class="w-10 h-10 rounded-lg flex items-center justify-center shadow-sm border border-dls-border bg-dls-surface">
<Package size={20} class="text-dls-secondary" />
</div>
<div>
<div class="flex items-center gap-2 mb-0.5">
<h4 class="text-sm font-semibold text-dls-text">{skill.name}</h4>
</div>
<Show when={skill.description}>
<p class="text-xs text-dls-secondary line-clamp-1">
{skill.description}
</p>
</Show>
</div>
<Button
variant="danger"
class="!px-3 !py-2 text-xs"
onClick={() => setUninstallTarget(s)}
disabled={props.busy || !props.canUseDesktopTools}
title={translate("skills.uninstall")}
>
{translate("skills.uninstall")}
</Button>
</div>
<button
type="button"
class="p-1.5 text-dls-secondary hover:text-dls-text hover:bg-dls-hover rounded-md transition-colors"
onClick={() => setUninstallTarget(skill)}
disabled={props.busy || !props.canUseDesktopTools}
title={translate("skills.uninstall")}
>
<Edit2 size={14} />
</button>
</div>
)}
</For>
@@ -169,20 +201,59 @@ export default function SkillsView(props: SkillsViewProps) {
</Show>
</div>
<div class="space-y-4">
<h3 class="text-[11px] font-bold text-dls-secondary uppercase tracking-widest">Recommended</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<For each={recommendedSkills()}>
{(item) => (
<div class="bg-dls-surface border border-dls-border rounded-xl p-4 flex items-start justify-between group hover:border-dls-border transition-all">
<div class="flex gap-4">
<div class="w-10 h-10 rounded-lg flex items-center justify-center shadow-sm border border-dls-border bg-dls-hover">
<item.icon size={20} class="text-dls-secondary" />
</div>
<div>
<div class="flex items-center gap-2 mb-0.5">
<h4 class="text-sm font-semibold text-dls-text">{item.title}</h4>
</div>
<p class="text-xs text-dls-secondary line-clamp-1">{item.description}</p>
</div>
</div>
<button
type="button"
class={`p-1.5 rounded-md transition-colors ${
item.disabled
? "text-dls-secondary opacity-40"
: "text-dls-secondary hover:text-dls-text hover:bg-dls-hover"
}`}
onClick={() => {
if (item.disabled) return;
item.onClick();
}}
disabled={item.disabled}
title={item.title}
>
<Plus size={16} />
</button>
</div>
)}
</For>
</div>
</div>
<Show when={uninstallOpen()}>
<div class="fixed inset-0 z-50 bg-gray-1/60 backdrop-blur-sm flex items-center justify-center p-4">
<div class="bg-gray-2 border border-gray-6/70 w-full max-w-md rounded-2xl shadow-2xl overflow-hidden">
<div class="fixed inset-0 z-50 bg-black/20 backdrop-blur-sm flex items-center justify-center p-4">
<div class="bg-dls-surface border border-dls-border w-full max-w-md rounded-2xl shadow-2xl overflow-hidden">
<div class="p-6">
<div class="flex items-start justify-between gap-4">
<div>
<h3 class="text-lg font-semibold text-gray-12">{translate("skills.uninstall_title")}</h3>
<p class="text-sm text-gray-11 mt-1">
<h3 class="text-lg font-semibold text-dls-text">{translate("skills.uninstall_title")}</h3>
<p class="text-sm text-dls-secondary mt-1">
{translate("skills.uninstall_warning").replace("{name}", uninstallTarget()?.name ?? "")}
</p>
</div>
</div>
<div class="mt-4 rounded-xl bg-gray-1/20 border border-gray-6 p-3 text-xs text-gray-11 font-mono break-all">
<div class="mt-4 rounded-xl bg-dls-hover border border-dls-border p-3 text-xs text-dls-secondary font-mono break-all">
{uninstallTarget()?.path}
</div>

View File

@@ -99,8 +99,6 @@ export type EngineRuntime = "direct" | "openwrk";
export type OnboardingStep = "welcome" | "local" | "server" | "connecting";
export type DashboardTab =
| "home"
| "sessions"
| "scheduled"
| "skills"
| "plugins"

View File

@@ -9,6 +9,18 @@ safelist: [
// OVERRIDE the base theme completely instead of extending it
colors: {
...radixColors,
dls: {
surface: "var(--dls-surface)",
sidebar: "var(--dls-sidebar)",
border: "var(--dls-border)",
accent: "var(--dls-accent)",
text: "var(--dls-text-primary)",
secondary: "var(--dls-text-secondary)",
hover: "var(--dls-hover)",
active: "var(--dls-active)",
},
white: "#ffffff",
black: "#000000",
}
}
};

View File

@@ -1,15 +1,34 @@
import os from "node:os";
import { defineConfig } from "vite";
import tailwindcss from "@tailwindcss/vite";
import solid from "vite-plugin-solid";
const portValue = Number.parseInt(process.env.PORT ?? "", 10);
const devPort = Number.isFinite(portValue) && portValue > 0 ? portValue : 5173;
const allowedHosts = new Set<string>();
const envAllowedHosts = process.env.VITE_ALLOWED_HOSTS ?? "";
const addHost = (value?: string | null) => {
const trimmed = value?.trim();
if (!trimmed) return;
allowedHosts.add(trimmed);
};
envAllowedHosts.split(",").forEach(addHost);
addHost(process.env.OPENWORK_PUBLIC_HOST ?? null);
const hostname = os.hostname();
addHost(hostname);
const shortHostname = hostname.split(".")[0];
if (shortHostname && shortHostname !== hostname) {
addHost(shortHostname);
}
export default defineConfig({
plugins: [tailwindcss(), solid()],
server: {
port: devPort,
strictPort: true,
...(allowedHosts.size > 0 ? { allowedHosts: Array.from(allowedHosts) } : {}),
},
build: {
target: "esnext",