Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
# AI Gateway (model auth)
# The orchestrator runs models (e.g. anthropic/claude-sonnet-4.6) through the
# Vercel AI Gateway. @ai-sdk/gateway reads AI_GATEWAY_API_KEY first, then falls
# back to VERCEL_OIDC_TOKEN (short-lived; refreshed by `vercel env pull`). Set
# AI_GATEWAY_API_KEY for a durable local key. Required to run the agent locally
# (including the dev-only simulator's live turns).
AI_GATEWAY_API_KEY=

# Discord
DISCORD_BOT_TOKEN=
DISCORD_BOT_CLIENT_ID=
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -186,3 +186,6 @@ src/lib/ai/skills/generated/

# workflow devkit generated routes
src/app/.well-known/

# Local simulator SQLite DB (dev-only)
.sim/
4 changes: 3 additions & 1 deletion knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@
"tags": ["-lintignore"],
"entry": [
"src/app/**/route.ts",
"src/app/**/page.tsx",
"src/app/**/layout.tsx",
"src/server/**/*.ts",
"src/bot/handlers/**/*.ts",
"scripts/*.ts",
"vercel.ts"
],
"project": ["src/**/*.ts", "scripts/**/*.ts"],
"project": ["src/**/*.{ts,tsx}", "scripts/**/*.ts"],
"ignoreExportsUsedInFile": true,
"ignore": ["src/lib/ai/tools/**"],
"drizzle": false
Expand Down
119 changes: 119 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/* Discord dark-theme tokens + a light reset. Surface/text/accent values mirror
the real client so the simulator reads as a faithful channel. */

:root {
/* Surfaces — modern (2025) high-contrast dark theme: darker, with the
rail/titlebar near-black and chat a touch lighter than the sidebar. */
--surface-titlebar: #161719;
--surface-rail: #161719;
--surface-sidebar: #1e1f22;
--surface-channel: #27292d;
--surface-input: #313338;
--surface-hover: #2c2e33;
--surface-active: #3b3e45;
--surface-userpanel: #121315;
--surface-message-hover: #2a2c31;
--surface-code: #1c1d20;
--surface-embed: #1c1d20;
--border-subtle: #2c2e33;

/* Brand + status */
--brand: #5865f2;
--status-online: #23a55a;

/* Channel-list text tiers */
--text-channel: #949ba4;
--text-faint: #80848e;

/* Text */
--text-normal: #dbdee1;
--text-muted: #949ba4;
--text-link: #00a8fc;
--text-bright: #f2f3f5;
--text-on-accent: #ffffff;

/* Mentions */
--mention-bg: #3c4270;
--mention-text: #c9cdfb;
--mention-hover-bg: #5865f2;

/* Buttons */
--btn-green: #248046;
--btn-green-hover: #1a6334;
--btn-red: #da373c;
--btn-red-hover: #a12828;
--btn-grey: #4e5058;
--btn-grey-hover: #6d6f78;

/* Embed accents */
--accent-amber: #ffaa00;
--accent-green: #34d399;
--accent-red: #ef4444;
--accent-grey: #9ca3af;

/* Misc */
--spoiler-bg: #1e1f22;
--app-badge-bg: #5865f2;

--font-gg: "gg sans", "Noto Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
--font-mono:
"gg mono", Consolas, "Andale Mono WT", "Andale Mono", "Lucida Console",
"Lucida Sans Typewriter", monospace;
}

*,
*::before,
*::after {
box-sizing: border-box;
}

html,
body {
margin: 0;
padding: 0;
height: 100%;
}

body {
background: var(--surface-channel);
color: var(--text-normal);
font-family: var(--font-gg);
font-size: 16px;
line-height: 1.375;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}

button {
font-family: inherit;
cursor: pointer;
}

input,
textarea {
font-family: inherit;
}

a {
color: var(--text-link);
text-decoration: none;
}

a:hover {
text-decoration: underline;
}

::-webkit-scrollbar {
width: 16px;
}

::-webkit-scrollbar-thumb {
background-color: var(--surface-rail);
border: 4px solid transparent;
background-clip: padding-box;
border-radius: 8px;
}

::-webkit-scrollbar-track {
background-color: transparent;
}
32 changes: 32 additions & 0 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { ReactNode } from "react";

import "./globals.css";

export function generateMetadata() {
return {
title: "Wack Hack · Discord Simulator",
description: "A faithful Discord dark-theme channel for iterating on bot message UX.",
};
}

interface RootLayoutProps {
children: ReactNode;
}

export default function RootLayout({ children }: RootLayoutProps) {
return (
<html lang="en" className="dark">
<body>
{/* Discord ships "gg sans" (proprietary); Noto Sans is its documented
fallback and the closest free match. React 19 hoists these to <head>. */}
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Noto+Sans:wght@400;500;600;700&display=swap"
/>
{children}
</body>
</html>
);
}
13 changes: 13 additions & 0 deletions src/app/simulator/avatar.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Discord assigns each user a colored default avatar; mirror that so the
// channel doesn't read as a wall of identical blurple circles.
const PALETTE = ["#5865f2", "#3ba55d", "#e67e22", "#ed4245", "#eb459e", "#9b59b6", "#11806a"];

export function avatarColor(id: string): string {
let hash = 0;
for (let i = 0; i < id.length; i += 1) hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
return PALETTE[hash % PALETTE.length];
}

export function initials(name: string): string {
return name.slice(0, 1).toUpperCase();
}
21 changes: 21 additions & 0 deletions src/app/simulator/components/ActionRow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"use client";

import type { SimActionRow } from "@/lib/simulator/types";

import { Button } from "./Button.tsx";
import styles from "./components.module.css";

interface ActionRowProps {
row: SimActionRow;
onDecide: (decision: "approve" | "deny", approvalId: string) => void;
}

export function ActionRow({ row, onDecide }: ActionRowProps) {
return (
<div className={styles.actionRow}>
{row.components.map((button, idx) => (
<Button key={button.custom_id ?? button.url ?? idx} button={button} onDecide={onDecide} />
))}
</div>
);
}
60 changes: 60 additions & 0 deletions src/app/simulator/components/Button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"use client";

import { useState } from "react";

import type { SimButton } from "@/lib/simulator/types";

import styles from "./components.module.css";

interface ButtonProps {
button: SimButton;
onDecide: (decision: "approve" | "deny", approvalId: string) => void;
}

const STYLE_CLASS: Record<number, string> = {
1: styles.buttonPrimary,
2: styles.buttonSecondary,
3: styles.buttonSuccess,
4: styles.buttonDanger,
5: styles.buttonLink,
};

/** Parse a `tool-approval:<decision>:<id>` custom_id, or null if not one. */
function parseApproval(
customId: string | undefined,
): { decision: "approve" | "deny"; approvalId: string } | null {
if (!customId) return null;
const parts = customId.split(":");
if (parts.length !== 3 || parts[0] !== "tool-approval") return null;
if (parts[1] !== "approve" && parts[1] !== "deny") return null;
return { decision: parts[1], approvalId: parts[2] };
}

export function Button({ button, onDecide }: ButtonProps) {
const [clicked, setClicked] = useState(false);
const approval = parseApproval(button.custom_id);
const className = `${styles.button} ${STYLE_CLASS[button.style] ?? styles.buttonSecondary}`;
const disabled = button.disabled || clicked;

const handleClick = (): void => {
if (!approval || disabled) return;
setClicked(true);
onDecide(approval.decision, approval.approvalId);
};

if (button.style === 5 && button.url) {
return (
<a className={className} href={button.url} target="_blank" rel="noreferrer noopener">
{button.emoji?.name ? `${button.emoji.name} ` : ""}
{button.label}
</a>
);
}

return (
<button type="button" className={className} disabled={disabled} onClick={handleClick}>
{button.emoji?.name ? `${button.emoji.name} ` : ""}
{button.label}
</button>
);
}
89 changes: 89 additions & 0 deletions src/app/simulator/components/ChatColumn.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"use client";

import type { SimMessage } from "@/lib/simulator/types";

import type { MentionResolver } from "../types.ts";

import styles from "../simulator.module.css";
import { Composer } from "./Composer.tsx";
import { MessageList } from "./MessageList.tsx";

interface ChatColumnProps {
channelName: string;
isThread: boolean;
parentName?: string;
parentId?: string;
messages: SimMessage[];
resolver: MentionResolver;
busy: boolean;
onSend: (content: string) => void;
onDecide: (decision: "approve" | "deny", approvalId: string) => void;
onSelectChannel: (id: string) => void;
}

function ThreadGlyph() {
return (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M5 4v6a4 4 0 0 0 4 4h9" />
</svg>
);
}

/** The single chat/thread column: breadcrumb header + messages + composer. */
export function ChatColumn({
channelName,
isThread,
parentName,
parentId,
messages,
resolver,
busy,
onSend,
onDecide,
onSelectChannel,
}: ChatColumnProps) {
return (
<main className={styles.chat}>
<header className={styles.crumbBar}>
{isThread && parentId ? (
<>
<button
type="button"
className={styles.crumbLink}
onClick={() => onSelectChannel(parentId)}
>
# {parentName}
</button>
<span className={styles.crumbSep}>›</span>
<span className={styles.crumbGlyph}>
<ThreadGlyph />
</span>
<span>{channelName}</span>
</>
) : (
<>
<span className={styles.crumbGlyph}>#</span>
<span>{channelName}</span>
</>
)}
</header>
<MessageList
messages={messages}
resolver={resolver}
channelName={channelName}
onDecide={onDecide}
/>
<Composer channelName={channelName} disabled={busy} resolver={resolver} onSend={onSend} />
</main>
);
}
Loading
Loading