diff --git a/client/index.html b/client/index.html
index 45d93b3..ca1f0b6 100644
--- a/client/index.html
+++ b/client/index.html
@@ -4,7 +4,7 @@
-
Claude Code
+ ccserver
diff --git a/client/src/App.jsx b/client/src/App.jsx
index ba42ed1..7e4da41 100644
--- a/client/src/App.jsx
+++ b/client/src/App.jsx
@@ -38,6 +38,20 @@ export default function App() {
saveThemeId(themeId);
}, [themeId]);
+ // Browser tab title: " ccserver" (hostname resolved server-side
+ // with the same precedence as the notify footer's _from: ). The
+ // static index.html fallback is "ccserver"; this upgrades it once the API
+ // answers. Silent on failure (e.g. token auth gate) -- the fallback stays.
+ // Idempotent, so React StrictMode's double mount is harmless.
+ useEffect(() => {
+ authFetch('/api/dirs/home')
+ .then((res) => (res.ok ? res.json() : null))
+ .then((data) => {
+ if (data?.hostname) document.title = `${data.hostname} ccserver`;
+ })
+ .catch(() => {});
+ }, []);
+
const openTerminalTab = useCallback((dirPath, { claudeSessionId = null, shell = false, sessionId = null, attachSessionId = null, sandbox = false, sandboxOpts = null, app = 'claude', model = null, resume = false } = {}) => {
const id = `terminal-${++tabIdCounter}`;
const dirName = dirPath.split(/[/\\]/).filter(Boolean).pop() || dirPath;
diff --git a/server/routes/dirs.js b/server/routes/dirs.js
index 1a9962f..6b3f1e9 100644
--- a/server/routes/dirs.js
+++ b/server/routes/dirs.js
@@ -2,11 +2,15 @@ import { readdir, mkdir, stat } from 'node:fs/promises';
import { join, resolve, basename } from 'node:path';
import { homedir } from 'node:os';
import { loadSandboxConfig } from '../ws/sandbox.js';
+import { resolvedHostname } from '../ws/notify.js';
export async function dirsRoute(fastify, opts) {
fastify.get('/dirs/home', async () => {
const { defaultApp, forceSandbox } = loadSandboxConfig();
- return { home: homedir(), defaultApp, forceSandbox };
+ // hostname for the browser tab title (" ccserver"): the same
+ // resolution the notify footer uses, so the tab matches _from: .
+ // Extra field, so existing clients are unaffected.
+ return { home: homedir(), defaultApp, forceSandbox, hostname: resolvedHostname() };
});
fastify.get('/dirs', async (request, reply) => {
diff --git a/server/ws/notify.js b/server/ws/notify.js
index 1e8bb14..5b79ab1 100644
--- a/server/ws/notify.js
+++ b/server/ws/notify.js
@@ -48,14 +48,21 @@ const LEVEL_EMOJI = { info: 'ℹ️', success: '✅', warning: '⚠️', error:
let subscriptions = [];
let notifyBroker = null; // { server, sockPath, dir, connections } | null
+// Hostname for attribution and the browser tab title: CCSERVER_HOSTNAME wins
+// over the config's notify.hostname, which in turn wins over the OS hostname
+// (same priority pattern as CCSERVER_DISCORD_WEBHOOK, see sandbox.js). Exported
+// so non-notify consumers (dirs.js /dirs/home -> client tab title) resolve the
+// same name the notify footer shows (_from: ).
+export function resolvedHostname() {
+ const notify = loadSandboxConfig().notify || {};
+ return process.env.CCSERVER_HOSTNAME || notify.hostname || hostname();
+}
+
function loadNotifyConfig() {
const notify = loadSandboxConfig().notify || { discordWebhook: null, subscriptions: [], hostname: null, attribution: true };
return {
...notify,
- // Hostname for attribution: CCSERVER_HOSTNAME wins over the config's
- // notify.hostname, which in turn wins over the OS hostname (same
- // priority pattern as CCSERVER_DISCORD_WEBHOOK, see sandbox.js).
- hostname: process.env.CCSERVER_HOSTNAME || notify.hostname || hostname(),
+ hostname: resolvedHostname(),
attribution: notify.attribution !== false,
};
}
diff --git a/server/ws/notify.test.js b/server/ws/notify.test.js
index 555df6d..8b66da9 100644
--- a/server/ws/notify.test.js
+++ b/server/ws/notify.test.js
@@ -17,6 +17,7 @@ import {
listSubscriptions,
restoreNotify,
sendNotification,
+ resolvedHostname,
} from './notify.js';
// Point CCSERVER_SANDBOX_CONFIG + CCSERVER_NOTIFY_PATH at temp files and
@@ -309,3 +310,26 @@ test('notify hostname precedence: env wins over config, config over os.hostname(
else process.env.CCSERVER_HOSTNAME = prevHost;
}
});
+
+// resolvedHostname() (exported for the browser tab title, dirs.js /dirs/home):
+// same precedence as the footer -- CCSERVER_HOSTNAME > notify.hostname >
+// os.hostname().
+test('resolvedHostname precedence: env > notify.hostname > os.hostname()', async () => {
+ const prevHost = process.env.CCSERVER_HOSTNAME;
+ try {
+ process.env.CCSERVER_HOSTNAME = 'env-host';
+ await withNotifyConfig({ notify: { hostname: 'cfg-host' } }, async () => {
+ assert.equal(resolvedHostname(), 'env-host');
+ });
+ delete process.env.CCSERVER_HOSTNAME;
+ await withNotifyConfig({ notify: { hostname: 'cfg-host' } }, async () => {
+ assert.equal(resolvedHostname(), 'cfg-host');
+ });
+ await withNotifyConfig({ notify: {} }, async () => {
+ assert.equal(resolvedHostname(), hostname());
+ });
+ } finally {
+ if (prevHost === undefined) delete process.env.CCSERVER_HOSTNAME;
+ else process.env.CCSERVER_HOSTNAME = prevHost;
+ }
+});
diff --git a/tests/tab-title.spec.js b/tests/tab-title.spec.js
new file mode 100644
index 0000000..4ae2b51
--- /dev/null
+++ b/tests/tab-title.spec.js
@@ -0,0 +1,13 @@
+import { test, expect } from '@playwright/test';
+
+// The browser tab title is " ccserver" (hostname resolved
+// server-side with the same precedence as the notify footer: CCSERVER_HOSTNAME
+// > notify.hostname > os.hostname()), set by the client after fetching
+// /api/dirs/home on mount. The static index.html fallback is "ccserver". No
+// session launch needed -- the mount fetch is enough. Served from the
+// production build (client/dist) by the e2e webServer, so this also covers the
+// real delivery path.
+test('tab title shows " ccserver"', async ({ page }) => {
+ await page.goto('/');
+ await expect(page).toHaveTitle(/^\S+ ccserver$/);
+});