feat(web): add product site and app surface

This commit is contained in:
Peter Steinberger 2026-05-08 08:11:08 +01:00
parent b925723fa6
commit a650538b55
No known key found for this signature in database
17 changed files with 1164 additions and 545 deletions

2
.gitignore vendored
View File

@ -2,6 +2,8 @@ node_modules/
apps/web/dist/
packages/sdk-ts/dist/
dist/
!apps/api/internal/webassets/dist/
!apps/api/internal/webassets/dist/**
data/
coverage.out
coverage.txt

View File

@ -2,11 +2,14 @@
## Unreleased
- Added a public product website at the web root while keeping the chat app at
`/app` locally and on `app.clickclack.chat` when served from that host.
- Added an agent-friendly ClickClack client mode to the Go binary with
`login`, `logout`, `whoami`, `status`, workspace/channel listing, message
send/list, and thread open/reply commands.
- Scoped stored CLI credentials and workspace/channel defaults to the saved
server URL, with `--user` / `CLICKCLACK_USER_ID` taking precedence over
stored bearer tokens unless `--token` is explicitly supplied.
- Documented the `clickclack.chat` product domain, `docs.clickclack.chat`
docs domain, and recommended bearer-token auth flow for hosted agents.
- Documented the `clickclack.chat` product domain, `app.clickclack.chat` app
domain, `docs.clickclack.chat` docs domain, and recommended bearer-token auth
flow for hosted agents.

View File

@ -31,7 +31,9 @@ go run ./apps/api/cmd/clickclack serve
## Documentation
Product domain: **[clickclack.chat](https://clickclack.chat)**. Docs domain:
Product domain: **[clickclack.chat](https://clickclack.chat)**. App domain:
**[app.clickclack.chat](https://app.clickclack.chat)**, with `/app` as the
local path. Docs domain:
**[docs.clickclack.chat](https://docs.clickclack.chat)**, built from `docs/`
by `pnpm docs:site`. The [docs/](docs/) tree is organised so each file has a
short `read_when` hint at the top — open the one that matches your change.
@ -74,8 +76,9 @@ go run ./apps/api/cmd/clickclack serve # http://localhost:8080
```
The dev fallback boots a default user, workspace, and channel so the SPA
loads into something useful on first hit. Disable it with
`--dev-bootstrap=false` for anything that isn't a local checkout.
loads into something useful at `/app`. The root path is the public product
website. Disable it with `--dev-bootstrap=false` for anything that isn't a
local checkout.
### Two-process dev loop

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ClickClack</title>
<script type="module" crossorigin src="/assets/index-4rv34_La.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Du33dVG9.css">
<script type="module" crossorigin src="/assets/index-DIRqaQC7.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CljoeKn6.css">
</head>
<body>
<div id="app"></div>

View File

@ -1,472 +1,14 @@
<script lang="ts">
import { onDestroy, onMount } from "svelte";
import { api } from "./lib/api";
import { markdown, time } from "./lib/format";
import type { Channel, DirectConversation, Message, RealtimeEvent, SearchResult, ThreadState, Upload, User, Workspace } from "./lib/types";
let user: User | null = null;
let workspaces: Workspace[] = [];
let channels: Channel[] = [];
let directConversations: DirectConversation[] = [];
let messages: Message[] = [];
let replies: Message[] = [];
let selectedWorkspaceID = "";
let selectedChannelID = "";
let selectedDirectID = "";
let selectedThread: Message | null = null;
let selectedThreadState: ThreadState | null = null;
let messageBody = "";
let replyBody = "";
let workspaceName = "";
let channelName = "";
let directMemberID = "";
let searchQuery = "";
let searchResults: SearchResult[] = [];
let pendingUpload: Upload | null = null;
let status = "loading";
let socket: WebSocket | null = null;
let reconnectTimer: number | undefined;
$: selectedWorkspace = workspaces.find((workspace) => workspace.id === selectedWorkspaceID);
$: selectedChannel = channels.find((channel) => channel.id === selectedChannelID);
$: selectedDirect = directConversations.find((conversation) => conversation.id === selectedDirectID);
onMount(() => {
void boot();
});
onDestroy(() => {
socket?.close();
if (reconnectTimer) window.clearTimeout(reconnectTimer);
});
async function boot() {
try {
const me = await api<{ user: User }>("/api/me");
user = me.user;
await loadWorkspaces();
status = "ready";
} catch (error) {
status = error instanceof Error ? error.message : "Could not load ClickClack";
}
}
async function loadWorkspaces() {
const data = await api<{ workspaces: Workspace[] }>("/api/workspaces");
workspaces = data.workspaces;
selectedWorkspaceID = selectedWorkspaceID || workspaces[0]?.id || "";
await loadChannels();
await loadDirectConversations();
connectRealtime();
}
async function createWorkspace() {
if (!workspaceName.trim()) return;
const data = await api<{ workspace: Workspace }>("/api/workspaces", {
method: "POST",
body: JSON.stringify({ name: workspaceName })
});
workspaceName = "";
workspaces = [...workspaces, data.workspace];
selectedWorkspaceID = data.workspace.id;
await loadChannels();
await loadDirectConversations();
connectRealtime();
}
async function loadChannels() {
if (!selectedWorkspaceID) return;
const data = await api<{ channels: Channel[] }>(`/api/workspaces/${selectedWorkspaceID}/channels`);
channels = data.channels;
selectedChannelID = channels.find((channel) => channel.id === selectedChannelID)?.id || channels[0]?.id || "";
selectedThread = null;
replies = [];
await loadMessages();
}
async function createChannel() {
if (!selectedWorkspaceID || !channelName.trim()) return;
const data = await api<{ channel: Channel }>(`/api/workspaces/${selectedWorkspaceID}/channels`, {
method: "POST",
body: JSON.stringify({ name: channelName, kind: "public" })
});
channelName = "";
channels = [...channels, data.channel];
selectedChannelID = data.channel.id;
await loadMessages();
}
async function loadMessages() {
if (selectedDirectID) {
const data = await api<{ messages: Message[] }>(`/api/dms/${selectedDirectID}/messages`);
messages = data.messages;
return;
}
if (!selectedChannelID) {
messages = [];
return;
}
const data = await api<{ messages: Message[] }>(`/api/channels/${selectedChannelID}/messages`);
messages = data.messages;
}
async function sendMessage() {
const body = messageBody.trim();
if (!body || (!selectedChannelID && !selectedDirectID)) return;
messageBody = "";
const path = selectedDirectID ? `/api/dms/${selectedDirectID}/messages` : `/api/channels/${selectedChannelID}/messages`;
const data = await api<{ message: Message }>(path, {
method: "POST",
body: JSON.stringify({ body })
});
if (pendingUpload) {
await api(`/api/messages/${data.message.id}/attachments`, {
method: "POST",
body: JSON.stringify({ upload_id: pendingUpload.id })
});
pendingUpload = null;
}
if (!messages.some((message) => message.id === data.message.id)) {
messages = [...messages, data.message];
}
}
async function openThread(message: Message) {
selectedThread = message;
const data = await api<{ root: Message; replies: Message[]; thread_state: ThreadState }>(`/api/messages/${message.id}/thread`);
selectedThread = data.root;
replies = data.replies;
selectedThreadState = data.thread_state;
}
async function sendReply() {
const body = replyBody.trim();
if (!body || !selectedThread) return;
replyBody = "";
const data = await api<{ message: Message; thread_state: ThreadState }>(`/api/messages/${selectedThread.id}/thread/replies`, {
method: "POST",
body: JSON.stringify({ body })
});
if (!replies.some((reply) => reply.id === data.message.id)) {
replies = [...replies, data.message];
}
selectedThreadState = data.thread_state;
}
async function searchMessages() {
if (!selectedWorkspaceID || !searchQuery.trim()) {
searchResults = [];
return;
}
const data = await api<{ results: SearchResult[] }>(
`/api/search?workspace_id=${encodeURIComponent(selectedWorkspaceID)}&q=${encodeURIComponent(searchQuery.trim())}`
);
searchResults = data.results;
}
async function uploadFile(event: Event) {
const input = event.currentTarget as HTMLInputElement;
const file = input.files?.[0];
if (!file || !selectedWorkspaceID) return;
const form = new FormData();
form.set("workspace_id", selectedWorkspaceID);
form.set("file", file);
const data = await api<{ upload: Upload }>("/api/uploads", { method: "POST", body: form });
pendingUpload = data.upload;
input.value = "";
}
async function loadDirectConversations() {
if (!selectedWorkspaceID) return;
const data = await api<{ conversations: DirectConversation[] }>(`/api/dms?workspace_id=${selectedWorkspaceID}`);
directConversations = data.conversations;
}
async function createDirectConversation() {
if (!selectedWorkspaceID || !directMemberID.trim()) return;
const data = await api<{ conversation: DirectConversation }>("/api/dms", {
method: "POST",
body: JSON.stringify({ workspace_id: selectedWorkspaceID, member_ids: [directMemberID.trim()] })
});
directMemberID = "";
directConversations = [...directConversations, data.conversation];
selectedDirectID = data.conversation.id;
selectedChannelID = "";
selectedThread = null;
await loadMessages();
}
function connectRealtime() {
socket?.close();
if (!selectedWorkspaceID) return;
const lastCursor = localStorage.getItem(`clickclack:${selectedWorkspaceID}:cursor`) || "";
const url = new URL("/api/realtime/ws", window.location.href);
url.protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
url.searchParams.set("workspace_id", selectedWorkspaceID);
if (lastCursor) url.searchParams.set("after_cursor", lastCursor);
socket = new WebSocket(url);
socket.addEventListener("message", (message) => {
const event = JSON.parse(String(message.data)) as RealtimeEvent;
if (event.cursor) localStorage.setItem(`clickclack:${selectedWorkspaceID}:cursor`, event.cursor);
void handleEvent(event);
});
socket.addEventListener("close", () => {
reconnectTimer = window.setTimeout(connectRealtime, 1200);
});
}
async function handleEvent(event: RealtimeEvent) {
if ((event.type === "channel.created" || event.type === "channel.updated") && event.workspace_id === selectedWorkspaceID) {
await loadChannels();
return;
}
if (
(event.channel_id === selectedChannelID || event.payload.direct_conversation_id === selectedDirectID) &&
(event.type === "message.created" || event.type === "message.updated" || event.type === "message.deleted")
) {
await loadMessages();
}
const rootID = event.payload.root_message_id || event.payload.message_id;
if (selectedThread && rootID === selectedThread.id) {
await openThread(selectedThread);
}
}
import ChatApp from "./ChatApp.svelte";
import ProductSite from "./ProductSite.svelte";
const path = window.location.pathname;
const isAppHost = window.location.hostname.startsWith("app.");
const isApp = isAppHost || path === "/app" || path.startsWith("/app/");
</script>
<svelte:head>
<meta name="color-scheme" content="light dark" />
</svelte:head>
<div class="shell">
<aside class="sidebar" aria-label="Workspace and channel navigation">
<div class="brand">
<div class="mark">cc</div>
<div>
<strong>ClickClack</strong>
<span>{user?.display_name || "local"}</span>
</div>
</div>
<section>
<div class="section-title">Workspaces</div>
<div class="nav-list">
{#each workspaces as workspace}
<button
class:active={workspace.id === selectedWorkspaceID}
onclick={async () => {
selectedWorkspaceID = workspace.id;
await loadChannels();
connectRealtime();
}}
>
{workspace.name}
</button>
{/each}
</div>
<form
class="inline-create"
onsubmit={(event) => {
event.preventDefault();
void createWorkspace();
}}
>
<input bind:value={workspaceName} placeholder="New workspace" aria-label="New workspace name" />
</form>
</section>
<section>
<div class="section-title">Channels</div>
<div class="nav-list channels">
{#each channels as channel}
<button
class:active={channel.id === selectedChannelID}
onclick={async () => {
selectedChannelID = channel.id;
selectedThread = null;
await loadMessages();
}}
>
<span>#</span>{channel.name}
</button>
{/each}
</div>
<form
class="inline-create"
onsubmit={(event) => {
event.preventDefault();
void createChannel();
}}
>
<input bind:value={channelName} placeholder="New channel" aria-label="New channel name" />
</form>
</section>
<section>
<div class="section-title">DMs</div>
<div class="nav-list channels">
{#each directConversations as conversation}
<button
class:active={conversation.id === selectedDirectID}
onclick={async () => {
selectedDirectID = conversation.id;
selectedChannelID = "";
selectedThread = null;
await loadMessages();
}}
>
<span>@</span>{conversation.members.map((member) => member.display_name).join(", ")}
</button>
{/each}
</div>
<form
class="inline-create"
onsubmit={(event) => {
event.preventDefault();
void createDirectConversation();
}}
>
<input bind:value={directMemberID} placeholder="Member user ID" aria-label="DM member user ID" />
</form>
</section>
</aside>
<main class="timeline">
<header class="topbar">
<div>
<p>{selectedWorkspace?.name || "Workspace"}</p>
<h1>{selectedDirect ? "@" + selectedDirect.members.map((member) => member.display_name).join(", ") : "#" + (selectedChannel?.name || "general")}</h1>
</div>
<form
class="search"
onsubmit={(event) => {
event.preventDefault();
void searchMessages();
}}
>
<input bind:value={searchQuery} placeholder="Search" aria-label="Search messages" />
<button type="submit">Search</button>
</form>
<div class="connection" data-state={socket?.readyState === WebSocket.OPEN ? "live" : "idle"}>
{socket?.readyState === WebSocket.OPEN ? "live" : status}
</div>
</header>
{#if searchResults.length > 0}
<div class="search-results" aria-label="Search results">
{#each searchResults as result (result.message.id)}
<button
onclick={async () => {
searchResults = [];
if (result.message.channel_id) {
selectedChannelID = result.message.channel_id;
selectedDirectID = "";
await loadMessages();
}
if (result.message.direct_conversation_id) {
selectedDirectID = result.message.direct_conversation_id;
selectedChannelID = "";
await loadMessages();
}
}}
>
<strong>{result.message.author?.display_name || "Local User"}</strong>
<span>{result.message.body}</span>
</button>
{/each}
</div>
{/if}
<div class="messages" aria-live="polite">
{#if messages.length === 0}
<div class="empty">
<strong>Quiet tide.</strong>
<span>Start with Markdown. Threads open from any root message.</span>
</div>
{/if}
{#each messages as message (message.id)}
<article class="message" class:selected={selectedThread?.id === message.id}>
<div class="avatar">{message.author?.display_name?.slice(0, 1) || "c"}</div>
<div class="message-body">
<header>
<strong>{message.author?.display_name || "Local User"}</strong>
<time>{time(message.created_at)}</time>
</header>
<div class="markdown">{@html markdown(message.body)}</div>
<button class="thread-button" onclick={() => openThread(message)}>Open thread</button>
</div>
</article>
{/each}
</div>
<form
class="composer"
onsubmit={(event) => {
event.preventDefault();
void sendMessage();
}}
>
<textarea bind:value={messageBody} rows="3" placeholder="Message with Markdown" aria-label="Message body"></textarea>
<div class="composer-actions">
<label class="upload-button">
<input type="file" aria-label="Upload file" onchange={uploadFile} />
Upload
</label>
{#if pendingUpload}
<span class="pending-upload">{pendingUpload.filename}</span>
{/if}
<button type="button" onclick={() => void sendMessage()}>Send</button>
</div>
</form>
</main>
<aside class="thread" class:open={selectedThread} aria-label="Thread pane">
{#if selectedThread}
<header>
<div>
<p>Thread</p>
<strong>{selectedThreadState?.reply_count || replies.length} replies</strong>
</div>
<button
aria-label="Close thread"
onclick={() => {
selectedThread = null;
replies = [];
}}
>
x
</button>
</header>
<article class="thread-root">
<strong>{selectedThread.author?.display_name || "Local User"}</strong>
<div class="markdown">{@html markdown(selectedThread.body)}</div>
</article>
<div class="reply-list">
{#each replies as reply (reply.id)}
<article class="reply">
<header>
<strong>{reply.author?.display_name || "Local User"}</strong>
<time>{time(reply.created_at)}</time>
</header>
<div class="markdown">{@html markdown(reply.body)}</div>
</article>
{/each}
</div>
<form
class="reply-composer"
onsubmit={(event) => {
event.preventDefault();
void sendReply();
}}
>
<textarea bind:value={replyBody} rows="3" placeholder="Reply in thread" aria-label="Reply body"></textarea>
<button type="button" onclick={() => void sendReply()}>Reply</button>
</form>
{:else}
<div class="thread-empty">
<strong>No thread open</strong>
<span>Pick a message to keep the side conversation tidy.</span>
</div>
{/if}
</aside>
</div>
{#if isApp}
<ChatApp />
{:else}
<ProductSite />
{/if}

472
apps/web/src/ChatApp.svelte Normal file
View File

@ -0,0 +1,472 @@
<script lang="ts">
import { onDestroy, onMount } from "svelte";
import { api } from "./lib/api";
import { markdown, time } from "./lib/format";
import type { Channel, DirectConversation, Message, RealtimeEvent, SearchResult, ThreadState, Upload, User, Workspace } from "./lib/types";
let user: User | null = null;
let workspaces: Workspace[] = [];
let channels: Channel[] = [];
let directConversations: DirectConversation[] = [];
let messages: Message[] = [];
let replies: Message[] = [];
let selectedWorkspaceID = "";
let selectedChannelID = "";
let selectedDirectID = "";
let selectedThread: Message | null = null;
let selectedThreadState: ThreadState | null = null;
let messageBody = "";
let replyBody = "";
let workspaceName = "";
let channelName = "";
let directMemberID = "";
let searchQuery = "";
let searchResults: SearchResult[] = [];
let pendingUpload: Upload | null = null;
let status = "loading";
let socket: WebSocket | null = null;
let reconnectTimer: number | undefined;
$: selectedWorkspace = workspaces.find((workspace) => workspace.id === selectedWorkspaceID);
$: selectedChannel = channels.find((channel) => channel.id === selectedChannelID);
$: selectedDirect = directConversations.find((conversation) => conversation.id === selectedDirectID);
onMount(() => {
void boot();
});
onDestroy(() => {
socket?.close();
if (reconnectTimer) window.clearTimeout(reconnectTimer);
});
async function boot() {
try {
const me = await api<{ user: User }>("/api/me");
user = me.user;
await loadWorkspaces();
status = "ready";
} catch (error) {
status = error instanceof Error ? error.message : "Could not load ClickClack";
}
}
async function loadWorkspaces() {
const data = await api<{ workspaces: Workspace[] }>("/api/workspaces");
workspaces = data.workspaces;
selectedWorkspaceID = selectedWorkspaceID || workspaces[0]?.id || "";
await loadChannels();
await loadDirectConversations();
connectRealtime();
}
async function createWorkspace() {
if (!workspaceName.trim()) return;
const data = await api<{ workspace: Workspace }>("/api/workspaces", {
method: "POST",
body: JSON.stringify({ name: workspaceName })
});
workspaceName = "";
workspaces = [...workspaces, data.workspace];
selectedWorkspaceID = data.workspace.id;
await loadChannels();
await loadDirectConversations();
connectRealtime();
}
async function loadChannels() {
if (!selectedWorkspaceID) return;
const data = await api<{ channels: Channel[] }>(`/api/workspaces/${selectedWorkspaceID}/channels`);
channels = data.channels;
selectedChannelID = channels.find((channel) => channel.id === selectedChannelID)?.id || channels[0]?.id || "";
selectedThread = null;
replies = [];
await loadMessages();
}
async function createChannel() {
if (!selectedWorkspaceID || !channelName.trim()) return;
const data = await api<{ channel: Channel }>(`/api/workspaces/${selectedWorkspaceID}/channels`, {
method: "POST",
body: JSON.stringify({ name: channelName, kind: "public" })
});
channelName = "";
channels = [...channels, data.channel];
selectedChannelID = data.channel.id;
await loadMessages();
}
async function loadMessages() {
if (selectedDirectID) {
const data = await api<{ messages: Message[] }>(`/api/dms/${selectedDirectID}/messages`);
messages = data.messages;
return;
}
if (!selectedChannelID) {
messages = [];
return;
}
const data = await api<{ messages: Message[] }>(`/api/channels/${selectedChannelID}/messages`);
messages = data.messages;
}
async function sendMessage() {
const body = messageBody.trim();
if (!body || (!selectedChannelID && !selectedDirectID)) return;
messageBody = "";
const path = selectedDirectID ? `/api/dms/${selectedDirectID}/messages` : `/api/channels/${selectedChannelID}/messages`;
const data = await api<{ message: Message }>(path, {
method: "POST",
body: JSON.stringify({ body })
});
if (pendingUpload) {
await api(`/api/messages/${data.message.id}/attachments`, {
method: "POST",
body: JSON.stringify({ upload_id: pendingUpload.id })
});
pendingUpload = null;
}
if (!messages.some((message) => message.id === data.message.id)) {
messages = [...messages, data.message];
}
}
async function openThread(message: Message) {
selectedThread = message;
const data = await api<{ root: Message; replies: Message[]; thread_state: ThreadState }>(`/api/messages/${message.id}/thread`);
selectedThread = data.root;
replies = data.replies;
selectedThreadState = data.thread_state;
}
async function sendReply() {
const body = replyBody.trim();
if (!body || !selectedThread) return;
replyBody = "";
const data = await api<{ message: Message; thread_state: ThreadState }>(`/api/messages/${selectedThread.id}/thread/replies`, {
method: "POST",
body: JSON.stringify({ body })
});
if (!replies.some((reply) => reply.id === data.message.id)) {
replies = [...replies, data.message];
}
selectedThreadState = data.thread_state;
}
async function searchMessages() {
if (!selectedWorkspaceID || !searchQuery.trim()) {
searchResults = [];
return;
}
const data = await api<{ results: SearchResult[] }>(
`/api/search?workspace_id=${encodeURIComponent(selectedWorkspaceID)}&q=${encodeURIComponent(searchQuery.trim())}`
);
searchResults = data.results;
}
async function uploadFile(event: Event) {
const input = event.currentTarget as HTMLInputElement;
const file = input.files?.[0];
if (!file || !selectedWorkspaceID) return;
const form = new FormData();
form.set("workspace_id", selectedWorkspaceID);
form.set("file", file);
const data = await api<{ upload: Upload }>("/api/uploads", { method: "POST", body: form });
pendingUpload = data.upload;
input.value = "";
}
async function loadDirectConversations() {
if (!selectedWorkspaceID) return;
const data = await api<{ conversations: DirectConversation[] }>(`/api/dms?workspace_id=${selectedWorkspaceID}`);
directConversations = data.conversations;
}
async function createDirectConversation() {
if (!selectedWorkspaceID || !directMemberID.trim()) return;
const data = await api<{ conversation: DirectConversation }>("/api/dms", {
method: "POST",
body: JSON.stringify({ workspace_id: selectedWorkspaceID, member_ids: [directMemberID.trim()] })
});
directMemberID = "";
directConversations = [...directConversations, data.conversation];
selectedDirectID = data.conversation.id;
selectedChannelID = "";
selectedThread = null;
await loadMessages();
}
function connectRealtime() {
socket?.close();
if (!selectedWorkspaceID) return;
const lastCursor = localStorage.getItem(`clickclack:${selectedWorkspaceID}:cursor`) || "";
const url = new URL("/api/realtime/ws", window.location.href);
url.protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
url.searchParams.set("workspace_id", selectedWorkspaceID);
if (lastCursor) url.searchParams.set("after_cursor", lastCursor);
socket = new WebSocket(url);
socket.addEventListener("message", (message) => {
const event = JSON.parse(String(message.data)) as RealtimeEvent;
if (event.cursor) localStorage.setItem(`clickclack:${selectedWorkspaceID}:cursor`, event.cursor);
void handleEvent(event);
});
socket.addEventListener("close", () => {
reconnectTimer = window.setTimeout(connectRealtime, 1200);
});
}
async function handleEvent(event: RealtimeEvent) {
if ((event.type === "channel.created" || event.type === "channel.updated") && event.workspace_id === selectedWorkspaceID) {
await loadChannels();
return;
}
if (
(event.channel_id === selectedChannelID || event.payload.direct_conversation_id === selectedDirectID) &&
(event.type === "message.created" || event.type === "message.updated" || event.type === "message.deleted")
) {
await loadMessages();
}
const rootID = event.payload.root_message_id || event.payload.message_id;
if (selectedThread && rootID === selectedThread.id) {
await openThread(selectedThread);
}
}
</script>
<svelte:head>
<meta name="color-scheme" content="light dark" />
</svelte:head>
<div class="shell">
<aside class="sidebar" aria-label="Workspace and channel navigation">
<div class="brand">
<div class="mark">cc</div>
<div>
<strong>ClickClack</strong>
<span>{user?.display_name || "local"}</span>
</div>
</div>
<section>
<div class="section-title">Workspaces</div>
<div class="nav-list">
{#each workspaces as workspace}
<button
class:active={workspace.id === selectedWorkspaceID}
onclick={async () => {
selectedWorkspaceID = workspace.id;
await loadChannels();
connectRealtime();
}}
>
{workspace.name}
</button>
{/each}
</div>
<form
class="inline-create"
onsubmit={(event) => {
event.preventDefault();
void createWorkspace();
}}
>
<input bind:value={workspaceName} placeholder="New workspace" aria-label="New workspace name" />
</form>
</section>
<section>
<div class="section-title">Channels</div>
<div class="nav-list channels">
{#each channels as channel}
<button
class:active={channel.id === selectedChannelID}
onclick={async () => {
selectedChannelID = channel.id;
selectedThread = null;
await loadMessages();
}}
>
<span>#</span>{channel.name}
</button>
{/each}
</div>
<form
class="inline-create"
onsubmit={(event) => {
event.preventDefault();
void createChannel();
}}
>
<input bind:value={channelName} placeholder="New channel" aria-label="New channel name" />
</form>
</section>
<section>
<div class="section-title">DMs</div>
<div class="nav-list channels">
{#each directConversations as conversation}
<button
class:active={conversation.id === selectedDirectID}
onclick={async () => {
selectedDirectID = conversation.id;
selectedChannelID = "";
selectedThread = null;
await loadMessages();
}}
>
<span>@</span>{conversation.members.map((member) => member.display_name).join(", ")}
</button>
{/each}
</div>
<form
class="inline-create"
onsubmit={(event) => {
event.preventDefault();
void createDirectConversation();
}}
>
<input bind:value={directMemberID} placeholder="Member user ID" aria-label="DM member user ID" />
</form>
</section>
</aside>
<main class="timeline">
<header class="topbar">
<div>
<p>{selectedWorkspace?.name || "Workspace"}</p>
<h1>{selectedDirect ? "@" + selectedDirect.members.map((member) => member.display_name).join(", ") : "#" + (selectedChannel?.name || "general")}</h1>
</div>
<form
class="search"
onsubmit={(event) => {
event.preventDefault();
void searchMessages();
}}
>
<input bind:value={searchQuery} placeholder="Search" aria-label="Search messages" />
<button type="submit">Search</button>
</form>
<div class="connection" data-state={socket?.readyState === WebSocket.OPEN ? "live" : "idle"}>
{socket?.readyState === WebSocket.OPEN ? "live" : status}
</div>
</header>
{#if searchResults.length > 0}
<div class="search-results" aria-label="Search results">
{#each searchResults as result (result.message.id)}
<button
onclick={async () => {
searchResults = [];
if (result.message.channel_id) {
selectedChannelID = result.message.channel_id;
selectedDirectID = "";
await loadMessages();
}
if (result.message.direct_conversation_id) {
selectedDirectID = result.message.direct_conversation_id;
selectedChannelID = "";
await loadMessages();
}
}}
>
<strong>{result.message.author?.display_name || "Local User"}</strong>
<span>{result.message.body}</span>
</button>
{/each}
</div>
{/if}
<div class="messages" aria-live="polite">
{#if messages.length === 0}
<div class="empty">
<strong>Quiet tide.</strong>
<span>Start with Markdown. Threads open from any root message.</span>
</div>
{/if}
{#each messages as message (message.id)}
<article class="message" class:selected={selectedThread?.id === message.id}>
<div class="avatar">{message.author?.display_name?.slice(0, 1) || "c"}</div>
<div class="message-body">
<header>
<strong>{message.author?.display_name || "Local User"}</strong>
<time>{time(message.created_at)}</time>
</header>
<div class="markdown">{@html markdown(message.body)}</div>
<button class="thread-button" onclick={() => openThread(message)}>Open thread</button>
</div>
</article>
{/each}
</div>
<form
class="composer"
onsubmit={(event) => {
event.preventDefault();
void sendMessage();
}}
>
<textarea bind:value={messageBody} rows="3" placeholder="Message with Markdown" aria-label="Message body"></textarea>
<div class="composer-actions">
<label class="upload-button">
<input type="file" aria-label="Upload file" onchange={uploadFile} />
Upload
</label>
{#if pendingUpload}
<span class="pending-upload">{pendingUpload.filename}</span>
{/if}
<button type="button" onclick={() => void sendMessage()}>Send</button>
</div>
</form>
</main>
<aside class="thread" class:open={selectedThread} aria-label="Thread pane">
{#if selectedThread}
<header>
<div>
<p>Thread</p>
<strong>{selectedThreadState?.reply_count || replies.length} replies</strong>
</div>
<button
aria-label="Close thread"
onclick={() => {
selectedThread = null;
replies = [];
}}
>
x
</button>
</header>
<article class="thread-root">
<strong>{selectedThread.author?.display_name || "Local User"}</strong>
<div class="markdown">{@html markdown(selectedThread.body)}</div>
</article>
<div class="reply-list">
{#each replies as reply (reply.id)}
<article class="reply">
<header>
<strong>{reply.author?.display_name || "Local User"}</strong>
<time>{time(reply.created_at)}</time>
</header>
<div class="markdown">{@html markdown(reply.body)}</div>
</article>
{/each}
</div>
<form
class="reply-composer"
onsubmit={(event) => {
event.preventDefault();
void sendReply();
}}
>
<textarea bind:value={replyBody} rows="3" placeholder="Reply in thread" aria-label="Reply body"></textarea>
<button type="button" onclick={() => void sendReply()}>Reply</button>
</form>
{:else}
<div class="thread-empty">
<strong>No thread open</strong>
<span>Pick a message to keep the side conversation tidy.</span>
</div>
{/if}
</aside>
</div>

View File

@ -0,0 +1,136 @@
<script lang="ts">
const docsURL = "https://docs.clickclack.chat";
const appURL = ["localhost", "127.0.0.1", "::1"].includes(window.location.hostname)
? "/app"
: "https://app.clickclack.chat";
const repoURL = "https://github.com/openclaw/clickclack";
const features = [
["Single binary", "Go server, embedded Svelte app, embedded migrations, local SQLite and uploads."],
["Threads that recover", "Slack-style one-level threads with durable event replay after reconnects."],
["Agent-friendly", "A CLI, OpenAPI contract, TypeScript SDK, webhooks, and slash-command shapes."],
["Self-host first", "SQLite is the default, not the demo. Postgres can arrive behind the store layer."],
];
const commands = [
"clickclack serve --data ./data",
"clickclack login --magic-token mgt_...",
"clickclack send --channel general \"deploy started\"",
"clickclack threads reply msg_... --stdin <summary.md",
];
</script>
<svelte:head>
<title>ClickClack - Self-hostable chat with claws</title>
<meta
name="description"
content="ClickClack is a self-hostable chat app with Slack-style threads, durable realtime, an agent-friendly CLI, OpenAPI, and a TypeScript SDK."
/>
<meta name="color-scheme" content="light dark" />
</svelte:head>
<main class="product-site">
<section class="hero">
<div class="hero-bg" aria-hidden="true">
<div class="workspace-rail">
<span>cc</span>
<span>ops</span>
<span>bots</span>
</div>
<div class="timeline-preview">
<div class="preview-top">
<span># release-room</span>
<strong>live</strong>
</div>
<article>
<b>Mira</b>
<p>Cut v0.1 once e2e and docs are green.</p>
</article>
<article>
<b>build-bot</b>
<p><code>pnpm test:e2e</code> passed. No skipped checks.</p>
</article>
<article class="thread-line">
<b>Peter</b>
<p>Thread this with deploy notes and the Hetzner target.</p>
</article>
</div>
<div class="thread-preview">
<span>Thread</span>
<p>One level. No reply-tree archaeology.</p>
</div>
</div>
<nav class="product-nav" aria-label="Product navigation">
<a class="brand-lockup" href="/">
<span class="brand-mark">cc</span>
<span>ClickClack</span>
</a>
<div>
<a href={docsURL}>Docs</a>
<a href={appURL}>App</a>
<a href={repoURL}>GitHub</a>
</div>
</nav>
<div class="hero-copy">
<p class="eyebrow">Self-hostable chat. Serious tool. Mild brine.</p>
<h1>ClickClack</h1>
<p class="lede">
A single-binary chat app for teams, communities, bots, and agents:
Slack-style threads, durable realtime, OpenAPI, SQLite, and a CLI that
can drive the whole thing from a shell.
</p>
<div class="hero-actions">
<a class="primary-action" href={appURL}>Open app</a>
<a class="secondary-action" href={docsURL}>Read docs</a>
</div>
</div>
</section>
<section class="product-band intro-band" aria-label="Product summary">
<div>
<p class="section-kicker">What it is</p>
<h2>Chat infrastructure that stays boring when the socket drops.</h2>
</div>
<p>
WebSocket is the pipe. The database is the truth. Every durable message,
thread reply, reaction, and channel update can be recovered over HTTP with
a cursor, so clients and agents can reconnect without drama.
</p>
</section>
<section class="feature-grid" aria-label="Feature highlights">
{#each features as feature}
<article>
<h3>{feature[0]}</h3>
<p>{feature[1]}</p>
</article>
{/each}
</section>
<section class="product-band cli-band" aria-label="CLI examples">
<div>
<p class="section-kicker">Agent path</p>
<h2>A friendly CLI, no LLM baked in.</h2>
<p>
External agents, CI jobs, and humans use the same public API as the web
app. Tokens and workspace defaults are scoped per server, so switching
hosts does not leak credentials or stale IDs.
</p>
</div>
<pre aria-label="CLI command examples">{commands.join("\n")}</pre>
</section>
<section class="product-band docs-band" aria-label="Docs and app destinations">
<div>
<p class="section-kicker">Destinations</p>
<h2>Product at the root. Docs and app where people expect them.</h2>
</div>
<div class="destination-list">
<a href="/">clickclack.chat <span>Product website</span></a>
<a href={docsURL}>docs.clickclack.chat <span>Architecture, API, deploy</span></a>
<a href={appURL}>app.clickclack.chat <span>Hosted app surface</span></a>
</div>
</section>
</main>

View File

@ -1,5 +1,6 @@
import { mount } from "svelte";
import App from "./App.svelte";
import "./product.css";
import "./styles.css";
const app = mount(App, {

433
apps/web/src/product.css Normal file
View File

@ -0,0 +1,433 @@
.product-site {
min-height: 100vh;
background: #f7f3ea;
color: #151716;
font-family:
"Avenir Next", "Segoe UI", ui-sans-serif, system-ui, sans-serif;
}
.product-site a {
color: inherit;
text-decoration: none;
}
.hero {
position: relative;
display: grid;
min-height: clamp(680px, 92vh, 920px);
overflow: hidden;
isolation: isolate;
background:
linear-gradient(90deg, rgba(12, 31, 35, 0.78), rgba(12, 31, 35, 0.18) 62%, rgba(12, 31, 35, 0.7)),
#103033;
color: #fff8ee;
}
.hero::after {
position: absolute;
inset: auto 0 0;
height: 22vh;
min-height: 130px;
background: linear-gradient(0deg, #f7f3ea 8%, rgba(247, 243, 234, 0));
content: "";
z-index: -1;
}
.hero::before {
position: absolute;
inset: 0 38% 0 0;
background: linear-gradient(90deg, rgba(8, 28, 30, 0.98), rgba(8, 28, 30, 0));
content: "";
z-index: -1;
}
.hero-bg {
position: absolute;
inset: 0 0 0 clamp(260px, 28vw, 460px);
display: grid;
grid-template-columns: 92px minmax(360px, 1fr) minmax(260px, 28vw);
gap: 18px;
padding: 102px clamp(18px, 5vw, 70px) 80px;
opacity: 0.36;
z-index: -2;
}
.workspace-rail,
.timeline-preview,
.thread-preview {
border: 1px solid rgba(255, 248, 238, 0.24);
background: rgba(255, 248, 238, 0.12);
box-shadow: 0 28px 90px rgba(0, 0, 0, 0.24);
backdrop-filter: blur(18px);
}
.workspace-rail {
display: grid;
align-content: start;
gap: 14px;
padding: 16px;
}
.workspace-rail span {
display: grid;
place-items: center;
min-height: 54px;
border-radius: 8px;
background: rgba(255, 248, 238, 0.18);
color: #fff8ee;
font-weight: 900;
text-transform: uppercase;
}
.timeline-preview {
align-self: stretch;
padding: clamp(18px, 3vw, 34px);
}
.preview-top {
display: flex;
justify-content: space-between;
margin-bottom: 48px;
color: #b9ddd9;
font-weight: 800;
}
.preview-top strong {
color: #7ce0b5;
}
.timeline-preview article,
.thread-preview {
max-width: 720px;
margin: 0 0 18px;
border-radius: 8px;
background: rgba(255, 248, 238, 0.16);
padding: 18px;
}
.timeline-preview b,
.thread-preview span {
color: #ff8a70;
}
.timeline-preview p,
.thread-preview p {
margin: 6px 0 0;
color: #fff8ee;
font-size: clamp(17px, 1.8vw, 24px);
}
.timeline-preview code {
border-radius: 5px;
background: rgba(12, 31, 35, 0.58);
padding: 2px 5px;
}
.thread-line {
margin-left: clamp(0px, 8vw, 130px);
}
.thread-preview {
align-self: center;
min-height: 260px;
}
.product-nav {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 24px clamp(18px, 5vw, 70px);
font-weight: 800;
}
.product-nav > div {
display: flex;
gap: clamp(14px, 3vw, 34px);
}
.brand-lockup {
display: inline-flex;
align-items: center;
gap: 10px;
}
.brand-mark {
display: grid;
place-items: center;
width: 40px;
height: 40px;
border-radius: 8px;
background: #ff6f55;
color: #151716;
font-weight: 950;
text-transform: uppercase;
}
.hero-copy {
position: relative;
z-index: 2;
align-self: center;
width: min(780px, calc(100vw - 36px));
padding: 0 clamp(18px, 5vw, 70px) 16vh;
}
.eyebrow,
.section-kicker {
margin: 0 0 14px;
color: #ffb09f;
font-size: 13px;
font-weight: 950;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.hero h1,
.product-band h2 {
margin: 0;
letter-spacing: 0;
}
.hero h1 {
font-family: Georgia, "Times New Roman", serif;
font-size: clamp(66px, 13vw, 152px);
font-weight: 900;
line-height: 0.9;
}
.lede {
width: min(680px, 100%);
margin: 26px 0 0;
color: #fff3df;
font-size: clamp(20px, 2.2vw, 31px);
line-height: 1.18;
}
.hero-actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 32px;
}
.primary-action,
.secondary-action {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 48px;
border-radius: 8px;
padding: 0 18px;
font-weight: 950;
}
.primary-action {
background: #ff6f55;
color: #151716;
}
.secondary-action {
border: 1px solid rgba(255, 248, 238, 0.42);
color: #fff8ee;
}
.product-band {
display: grid;
grid-template-columns: minmax(260px, 0.9fr) minmax(280px, 1.1fr);
gap: clamp(28px, 6vw, 90px);
padding: clamp(58px, 8vw, 112px) clamp(18px, 5vw, 70px);
}
.product-band h2 {
max-width: 720px;
font-size: clamp(34px, 5vw, 74px);
line-height: 0.96;
}
.product-band p {
margin: 0;
color: #3f4743;
font-size: clamp(18px, 2.1vw, 27px);
line-height: 1.34;
}
.intro-band {
padding-top: 0;
}
.intro-band .section-kicker,
.cli-band .section-kicker,
.docs-band .section-kicker {
color: #b84632;
}
.feature-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
border-block: 1px solid #c8c0b2;
}
.feature-grid article {
min-height: 250px;
border-right: 1px solid #c8c0b2;
padding: clamp(22px, 3vw, 40px);
}
.feature-grid article:nth-child(even) {
background: #e8f0e9;
}
.feature-grid article:last-child {
border-right: 0;
}
.feature-grid h3 {
margin: 0 0 44px;
font-size: 24px;
}
.feature-grid p {
margin: 0;
color: #48504c;
line-height: 1.45;
}
.cli-band {
background: #162a2d;
color: #fff8ee;
}
.cli-band p {
color: #c9d8d4;
}
.cli-band pre {
overflow: auto;
align-self: start;
margin: 0;
border: 1px solid rgba(255, 248, 238, 0.18);
border-radius: 8px;
background: #071314;
color: #bdf1dc;
padding: clamp(18px, 3vw, 32px);
line-height: 1.7;
}
.docs-band {
background: #fffaf0;
}
.destination-list {
display: grid;
gap: 12px;
}
.destination-list a {
display: flex;
justify-content: space-between;
gap: 20px;
border-top: 1px solid #cbc2b3;
padding: 18px 0;
font-size: clamp(21px, 3vw, 40px);
font-weight: 900;
}
.destination-list span {
color: #6f7772;
font-size: 15px;
font-weight: 700;
text-align: right;
}
@media (prefers-color-scheme: dark) {
.product-site {
background: #101616;
color: #fff8ee;
}
.hero::after {
background: linear-gradient(0deg, #101616 8%, rgba(16, 22, 22, 0));
}
.product-band p,
.feature-grid p,
.destination-list span {
color: #bac7c3;
}
.feature-grid {
border-color: #34413e;
}
.feature-grid article {
border-color: #34413e;
}
.feature-grid article:nth-child(even),
.docs-band {
background: #182221;
}
}
@media (max-width: 900px) {
.hero::before {
inset-right: 0;
}
.hero-bg {
inset: 0;
grid-template-columns: 58px minmax(0, 1fr);
padding-top: 90px;
opacity: 0.38;
}
.thread-preview {
display: none;
}
.product-band,
.feature-grid {
grid-template-columns: 1fr;
}
.feature-grid article,
.feature-grid article:last-child {
min-height: 0;
border-right: 0;
border-bottom: 1px solid #c8c0b2;
}
}
@media (max-width: 620px) {
.product-nav {
align-items: flex-start;
flex-direction: column;
}
.hero {
min-height: 760px;
}
.hero-bg {
grid-template-columns: 1fr;
opacity: 0.42;
}
.workspace-rail {
display: none;
}
.hero-copy {
padding-bottom: 90px;
}
.destination-list a {
flex-direction: column;
}
.destination-list span {
text-align: left;
}
}

View File

@ -13,8 +13,9 @@ WebSocket pipe, and a framework-neutral TypeScript SDK so the bots feel at home.
It's built for small teams, internal tools, communities, and anyone who would
rather host their own.
Product domain: [clickclack.chat](https://clickclack.chat). Docs domain:
[docs.clickclack.chat](https://docs.clickclack.chat).
Product domain: [clickclack.chat](https://clickclack.chat). App domain:
[app.clickclack.chat](https://app.clickclack.chat), with `/app` as the local
path. Docs domain: [docs.clickclack.chat](https://docs.clickclack.chat).
## Why ClickClack
@ -40,8 +41,8 @@ go run ./apps/api/cmd/clickclack serve
```
The dev fallback boots a default user, workspace, and channel so the SPA loads
into something useful on first hit. Disable it for anything that isn't a local
clone.
into something useful at `/app`. The root path is the product website. Disable
it for anything that isn't a local clone.
[Get the full quickstart →](quickstart.html)

View File

@ -10,6 +10,13 @@ ClickClack ships as one Go binary that embeds the Svelte SPA and the SQL
migrations. The deployment story is "drop a binary on a box, point it at a
data directory, run it behind a reverse proxy."
Public surfaces:
- `clickclack.chat` — product website.
- `app.clickclack.chat` — chat app. The same app is also available at `/app`
for local development and simple single-host deployments.
- `docs.clickclack.chat` — documentation site built by `pnpm docs:site`.
## Single binary
```sh

View File

@ -17,9 +17,10 @@ pnpm build
go run ./apps/api/cmd/clickclack serve
```
Open `http://localhost:8080`. You should see the SPA with a `Local Captain`
user already signed in via the dev fallback. That's the empty-dev path —
useful for poking around, not what you want long-term.
Open `http://localhost:8080` for the product website. Open
`http://localhost:8080/app` for the chat app with a `Local Captain` user
already signed in via the dev fallback. That's the empty-dev path — useful for
poking around, not what you want long-term.
## 2. Replace the dev user with a real owner

View File

@ -42,6 +42,17 @@ function isolatedHome(): NodeJS.ProcessEnv {
};
}
test("product website links to app and docs", async ({ page }) => {
await page.goto("/");
await expect(page.getByRole("heading", { name: "ClickClack" })).toBeVisible();
await expect(page.getByRole("link", { name: "Open app" })).toHaveAttribute("href", "/app");
await expect(page.getByRole("link", { name: "Read docs" })).toHaveAttribute(
"href",
"https://docs.clickclack.chat",
);
await expect(page.getByText("Self-hostable chat. Serious tool. Mild brine.")).toBeVisible();
});
test("sends messages, searches, uploads, opens a thread, and creates a DM", async ({ page }) => {
const consoleMessages: string[] = [];
page.on("console", (message) => consoleMessages.push(`${message.type()}: ${message.text()}`));
@ -69,7 +80,7 @@ test("sends messages, searches, uploads, opens a thread, and creates a DM", asyn
{ cwd: process.cwd(), encoding: "utf8" },
).trim();
await page.goto("/");
await page.goto("/app");
await page.getByRole("button", { name: "# general" }).click();
await expect(page.getByRole("heading", { name: "#general" })).toBeVisible();