diff --git a/.test-favicon.mjs b/.test-favicon.mjs
new file mode 100644
index 0000000000000000000000000000000000000000..dc2c9a565ea7fb0daa40335dc8469e14564d1ac9
--- /dev/null
+++ b/.test-favicon.mjs
@@ -0,0 +1,54 @@
+import path from "path";
+import fs from "fs";
+
+const FAVICON_CANDIDATES = [
+ "favicon.svg", "favicon.ico", "favicon.png",
+ "public/favicon.svg", "public/favicon.ico", "public/favicon.png",
+ "app/favicon.ico", "app/favicon.png", "app/icon.svg", "app/icon.png", "app/icon.ico",
+ "src/favicon.ico", "src/favicon.svg", "src/app/favicon.ico", "src/app/icon.svg", "src/app/icon.png",
+];
+
+const ICON_SOURCE_FILES = [
+ "index.html", "public/index.html",
+ "app/routes/__root.tsx", "src/routes/__root.tsx",
+ "app/root.tsx", "src/root.tsx", "src/index.html",
+];
+
+const LINK_ICON_HTML_RE = /]*rel=["'](?:icon|shortcut icon)["'][^>]*href=["']([^"'?]+)/i;
+const LINK_ICON_OBJ_RE = /rel:\s*["'](?:icon|shortcut icon)["'][^}]*href:\s*["']([^"'?]+)/i;
+
+function test(projectCwd) {
+ console.log("--- Testing:", projectCwd, "---");
+
+ for (const c of FAVICON_CANDIDATES) {
+ const full = path.join(projectCwd, c);
+ if (fs.existsSync(full) && fs.statSync(full).isFile()) {
+ console.log(" Phase 1 FOUND:", c);
+ return;
+ }
+ }
+ console.log(" Phase 1: no match");
+
+ for (const sf of ICON_SOURCE_FILES) {
+ const full = path.join(projectCwd, sf);
+ if (!fs.existsSync(full)) continue;
+ const content = fs.readFileSync(full, "utf8");
+ const href = content.match(LINK_ICON_HTML_RE)?.[1] || content.match(LINK_ICON_OBJ_RE)?.[1];
+ if (href) {
+ const clean = href.replace(/^\//, "");
+ const tryPaths = [path.join(projectCwd, "public", clean), path.join(projectCwd, clean)];
+ for (const p of tryPaths) {
+ if (fs.existsSync(p)) {
+ console.log(" Phase 2 FOUND:", sf, "->", href, "->", p);
+ return;
+ }
+ }
+ console.log(" Phase 2 href found but file missing:", href);
+ }
+ }
+ console.log(" Phase 2: no match");
+}
+
+test("/Users/theo/Code/Work/ct/t3chat");
+test("/Users/theo/Code/Work/lawn");
+test(process.cwd());
diff --git a/README.md b/README.md
index 26f95f802cd2f835061c524f85b10644e928581f..9c231a3bf9d15dcf4c4b2add9582a97774b2f6c7 100644
--- a/README.md
+++ b/README.md
@@ -91,7 +91,7 @@ npx t3
Set `T3CODE_DEV_INSTANCE` to any value to deterministically shift all dev ports together.
-- Default ports: server `3773`, web `5173`
+- Default ports: server `3773`, web `5733`
- Shifted ports: `base + offset` (offset is hashed from `T3CODE_DEV_INSTANCE`)
- Example: `T3CODE_DEV_INSTANCE=branch-a bun run dev:desktop`
diff --git a/apps/desktop/scripts/dev-electron.mjs b/apps/desktop/scripts/dev-electron.mjs
index c1dfad2a390ed326af541aacb757b6edbdf1d7c6..28970a2cc2030dc5bd3e007e52db98cf97ee2d85 100644
--- a/apps/desktop/scripts/dev-electron.mjs
+++ b/apps/desktop/scripts/dev-electron.mjs
@@ -5,7 +5,7 @@ import waitOn from "wait-on";
import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs";
-const port = Number(process.env.ELECTRON_RENDERER_PORT ?? 5173);
+const port = Number(process.env.ELECTRON_RENDERER_PORT ?? 5733);
const devServerUrl = `http://localhost:${port}`;
await waitOn({
diff --git a/apps/server/package.json b/apps/server/package.json
index 20e804e5bfdc0ae4adc4c34b5e1312826d95e36d..f37ada2f2e05cadbe430f9c1925ff0d76b4e91ea 100644
--- a/apps/server/package.json
+++ b/apps/server/package.json
@@ -10,7 +10,7 @@
"type": "module",
"main": "./dist/index.mjs",
"scripts": {
- "dev": "VITE_DEV_SERVER_URL=${VITE_DEV_SERVER_URL:-http://localhost:5173} bun run src/index.ts",
+ "dev": "VITE_DEV_SERVER_URL=${VITE_DEV_SERVER_URL:-http://localhost:5733} bun run src/index.ts",
"build": "tsdown && node scripts/bundle-client.ts",
"start": "node dist/index.mjs",
"prepare": "effect-language-service patch",
diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts
index f00a99019c16ae0173c88d3478e4fa7379776310..9918627e922c410cc90052634e7afc2b8f4f23de 100644
--- a/apps/server/src/wsServer.ts
+++ b/apps/server/src/wsServer.ts
@@ -230,8 +230,166 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
});
});
+ // Well-known favicon paths checked in order.
+ const FAVICON_CANDIDATES = [
+ "favicon.svg",
+ "favicon.ico",
+ "favicon.png",
+ "public/favicon.svg",
+ "public/favicon.ico",
+ "public/favicon.png",
+ "app/favicon.ico",
+ "app/favicon.png",
+ "app/icon.svg",
+ "app/icon.png",
+ "app/icon.ico",
+ "src/favicon.ico",
+ "src/favicon.svg",
+ "src/app/favicon.ico",
+ "src/app/icon.svg",
+ "src/app/icon.png",
+ ];
+
+ // Files that may contain a or icon metadata declaration.
+ const ICON_SOURCE_FILES = [
+ "index.html",
+ "public/index.html",
+ "app/routes/__root.tsx",
+ "src/routes/__root.tsx",
+ "app/root.tsx",
+ "src/root.tsx",
+ "src/index.html",
+ ];
+
+ // Extract icon href from HTML/TSX source content.
+ // Matches or { rel: "icon", href: "…" } patterns.
+ const LINK_ICON_HTML_RE = /]*rel=["'](?:icon|shortcut icon)["'][^>]*href=["']([^"'?]+)/i;
+ const LINK_ICON_OBJ_RE = /rel:\s*["'](?:icon|shortcut icon)["'][^}]*href:\s*["']([^"'?]+)/i;
+ function extractIconHref(source: string): string | null {
+ const htmlMatch = source.match(LINK_ICON_HTML_RE);
+ if (htmlMatch?.[1]) return htmlMatch[1];
+ const objMatch = source.match(LINK_ICON_OBJ_RE);
+ if (objMatch?.[1]) return objMatch[1];
+ return null;
+ }
+
+ /**
+ * Resolve an icon href (e.g. "/grass-logo.svg") to a file on disk.
+ * For href starting with "/" we look in public/ first, then project root.
+ */
+ function resolveIconHref(projectCwd: string, href: string): string[] {
+ const clean = href.replace(/^\//, "");
+ return [
+ path.join(projectCwd, "public", clean),
+ path.join(projectCwd, clean),
+ ];
+ }
+
+ function serveFaviconFile(
+ filePath: string,
+ res: http.ServerResponse,
+ ): void {
+ const ext = path.extname(filePath);
+ const contentType = MIME_TYPES[ext] ?? "application/octet-stream";
+ fs.readFile(filePath, (readErr, data) => {
+ if (readErr) {
+ res.writeHead(500, { "Content-Type": "text/plain" });
+ res.end("Read error");
+ return;
+ }
+ res.writeHead(200, {
+ "Content-Type": contentType,
+ "Cache-Control": "public, max-age=3600",
+ });
+ res.end(data);
+ });
+ }
+
// HTTP server — serves static files or redirects to Vite dev server
const httpServer = http.createServer((req, res) => {
+ const url = new URL(req.url ?? "/", `http://localhost:${port}`);
+
+ // Serve project favicons from their working directories.
+ // Handled before the dev redirect so it works in both modes.
+ if (url.pathname === "/api/project-favicon") {
+ const projectCwd = url.searchParams.get("cwd");
+ if (!projectCwd) {
+ res.writeHead(400, { "Content-Type": "text/plain" });
+ res.end("Missing cwd parameter");
+ return;
+ }
+
+ // Phase 1: Check well-known favicon paths.
+ const tryCandidates = (index: number) => {
+ if (index >= FAVICON_CANDIDATES.length) {
+ // Phase 2: Parse source files for icon link declarations.
+ trySourceFiles(0);
+ return;
+ }
+ const candidate = path.join(projectCwd, FAVICON_CANDIDATES[index]!);
+ if (!candidate.startsWith(projectCwd + path.sep)) {
+ tryCandidates(index + 1);
+ return;
+ }
+ fs.stat(candidate, (err, stats) => {
+ if (err || !stats?.isFile()) {
+ tryCandidates(index + 1);
+ return;
+ }
+ serveFaviconFile(candidate, res);
+ });
+ };
+
+ // Phase 2: Read source/HTML files and extract icon href references.
+ const trySourceFiles = (index: number) => {
+ if (index >= ICON_SOURCE_FILES.length) {
+ res.writeHead(404, { "Content-Type": "text/plain" });
+ res.end("No favicon found");
+ return;
+ }
+ const sourceFile = path.join(projectCwd, ICON_SOURCE_FILES[index]!);
+ fs.readFile(sourceFile, "utf8", (err, content) => {
+ if (err) {
+ trySourceFiles(index + 1);
+ return;
+ }
+ const href = extractIconHref(content);
+ if (!href) {
+ trySourceFiles(index + 1);
+ return;
+ }
+ const candidates = resolveIconHref(projectCwd, href);
+ tryResolvedPaths(candidates, 0, () => trySourceFiles(index + 1));
+ });
+ };
+
+ const tryResolvedPaths = (
+ paths: string[],
+ index: number,
+ onExhausted: () => void,
+ ) => {
+ if (index >= paths.length) {
+ onExhausted();
+ return;
+ }
+ const candidate = paths[index]!;
+ if (!candidate.startsWith(projectCwd + path.sep)) {
+ tryResolvedPaths(paths, index + 1, onExhausted);
+ return;
+ }
+ fs.stat(candidate, (err, stats) => {
+ if (err || !stats?.isFile()) {
+ tryResolvedPaths(paths, index + 1, onExhausted);
+ return;
+ }
+ serveFaviconFile(candidate, res);
+ });
+ };
+
+ tryCandidates(0);
+ return;
+ }
+
// In dev mode, redirect to Vite dev server
if (devUrl) {
res.writeHead(302, { Location: devUrl.href });
@@ -246,7 +404,6 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
return;
}
- const url = new URL(req.url ?? "/", `http://localhost:${port}`);
let filePath = path.join(staticDir, url.pathname);
// SPA fallback: if no file extension and not found, serve index.html
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx
index e3b6579c4a5d852122bab85efebd9470ed263c6f..35fd562650c99281956abe1d1b088727e2e7ede2 100644
--- a/apps/web/src/components/Sidebar.tsx
+++ b/apps/web/src/components/Sidebar.tsx
@@ -1,4 +1,4 @@
-import { ChevronRightIcon, TerminalIcon } from "lucide-react";
+import { ChevronRightIcon, FolderIcon, TerminalIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
DEFAULT_MODEL,
@@ -141,6 +141,50 @@ function T3Wordmark() {
);
}
+/**
+ * Derives the server's HTTP origin (scheme + host + port) from the same
+ * sources WsTransport uses, converting ws(s) to http(s).
+ */
+function getServerHttpOrigin(): string {
+ const bridgeUrl = window.desktopBridge?.getWsUrl();
+ const envUrl = import.meta.env.VITE_WS_URL as string | undefined;
+ const wsUrl =
+ bridgeUrl && bridgeUrl.length > 0
+ ? bridgeUrl
+ : envUrl && envUrl.length > 0
+ ? envUrl
+ : `ws://${window.location.hostname}:${window.location.port}`;
+ // Parse to extract just the origin, dropping path/query (e.g. ?token=…)
+ const httpUrl = wsUrl.replace(/^wss:/, "https:").replace(/^ws:/, "http:");
+ try {
+ return new URL(httpUrl).origin;
+ } catch {
+ return httpUrl;
+ }
+}
+
+const serverHttpOrigin = getServerHttpOrigin();
+
+function ProjectFavicon({ cwd }: { cwd: string }) {
+ const [status, setStatus] = useState<"loading" | "loaded" | "error">("loading");
+
+ const src = `${serverHttpOrigin}/api/project-favicon?cwd=${encodeURIComponent(cwd)}`;
+
+ if (status === "error") {
+ return ;
+ }
+
+ return (
+
setStatus("loaded")}
+ onError={() => setStatus("error")}
+ />
+ );
+}
+
export default function Sidebar() {
const { state, dispatch } = useStore();
const navigate = useNavigate();
@@ -573,6 +617,7 @@ export default function Sidebar() {
project.expanded ? "rotate-90" : ""
}`}
/>
+
{project.name}
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts
index eea4ea9476ebe6903a85a416224ca79bd3a68df9..652ebea30e13d01f0921d470707bd12f036cbfdd 100644
--- a/apps/web/vite.config.ts
+++ b/apps/web/vite.config.ts
@@ -3,7 +3,7 @@ import react from "@vitejs/plugin-react";
import { tanstackRouter } from "@tanstack/router-plugin/vite";
import { defineConfig } from "vite";
-const port = Number(process.env.PORT ?? 5173);
+const port = Number(process.env.PORT ?? 5733);
export default defineConfig({
plugins: [
diff --git a/scripts/dev-runner.mjs b/scripts/dev-runner.mjs
index 512ff34a492b107af9742e35e19ac6d6254c36ba..5020bea97c2d82cc9d90d29cc9aac0b429fa69be 100644
--- a/scripts/dev-runner.mjs
+++ b/scripts/dev-runner.mjs
@@ -4,7 +4,7 @@ import path from "node:path";
import { pathToFileURL } from "node:url";
const BASE_SERVER_PORT = 3773;
-const BASE_WEB_PORT = 5173;
+const BASE_WEB_PORT = 5733;
const MAX_HASH_OFFSET = 3000;
export const DEFAULT_DEV_STATE_DIR = path.join(homedir(), ".t3", "dev");
const MODE_ARGS = {