Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 1 addition & 1 deletion client/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#1e1e2e" />
<title>Claude Code</title>
<title>ccserver</title>
<link rel="manifest" href="/manifest.json" />
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/icon-192.png" />
Expand Down
14 changes: 14 additions & 0 deletions client/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,20 @@ export default function App() {
saveThemeId(themeId);
}, [themeId]);

// Browser tab title: "<hostname> ccserver" (hostname resolved server-side
// with the same precedence as the notify footer's _from: <host>). 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;
Expand Down
6 changes: 5 additions & 1 deletion server/routes/dirs.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ("<host> ccserver"): the same
// resolution the notify footer uses, so the tab matches _from: <host>.
// Extra field, so existing clients are unaffected.
return { home: homedir(), defaultApp, forceSandbox, hostname: resolvedHostname() };
});

fastify.get('/dirs', async (request, reply) => {
Expand Down
15 changes: 11 additions & 4 deletions server/ws/notify.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: <host>).
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,
};
}
Expand Down
24 changes: 24 additions & 0 deletions server/ws/notify.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
listSubscriptions,
restoreNotify,
sendNotification,
resolvedHostname,
} from './notify.js';

// Point CCSERVER_SANDBOX_CONFIG + CCSERVER_NOTIFY_PATH at temp files and
Expand Down Expand Up @@ -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;
}
});
13 changes: 13 additions & 0 deletions tests/tab-title.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { test, expect } from '@playwright/test';

// The browser tab title is "<hostname> 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 "<hostname> ccserver"', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveTitle(/^\S+ ccserver$/);
});