Compare commits

..
45 Commits
Author SHA1 Message Date
legop3 48b87b1127 change chat card title 2026-05-27 22:44:56 -04:00
legop3 796613876b some more social button changes 2026-05-27 00:28:17 -04:00
legop3 742232b882 overseer improvements and new social buttones 2026-05-26 23:58:23 -04:00
legop3 f56d865c8e move nickname entry to inside chat panel 2026-05-21 22:33:47 -04:00
legop3 7e7b07083a rover based colors! 2026-05-21 20:49:01 -04:00
legop3 d72bc0db03 removed title counters finally!!! 2026-05-21 03:06:33 -04:00
legop3 e9989da71a colors!!! 2026-05-21 03:02:29 -04:00
legop3 331302da01 pre cardcolor 2026-05-21 02:42:44 -04:00
legop3 bfd1a94030 beginning of my ideases 2026-05-21 01:57:59 -04:00
legop3 8727555788 overseer off when no one online 2026-05-21 00:56:54 -04:00
legop3 b62af19418 slight word change 2026-05-21 00:04:56 -04:00
legop3 4af93fe05a overseer vote panel improvements 2026-05-21 00:02:16 -04:00
legop3 f9f2e1e2ed FIXATYPOLOL 2026-05-20 23:29:10 -04:00
legop3 78c7e44d63 promp 2026-05-20 01:32:22 -04:00
legop3 ddb03bc809 no tools only messages !!! 2026-05-20 01:31:35 -04:00
legop3 2da58920c4 promp and clear command 2026-05-19 23:19:49 -04:00
legop3 1e5f282a6a promp 2026-05-19 22:32:37 -04:00
legop3 064aaee76c fullscreen prompt cookie saving, and more... 2026-05-19 21:52:03 -04:00
legop3 3a2c1bee35 spectator fix 2026-05-19 21:31:32 -04:00
legop3 e11361b5d5 awawawa 2026-05-19 21:29:51 -04:00
legop3 03929822e6 awawa 2026-05-19 21:27:53 -04:00
legop3 3ce9ea2cdc awaa 2026-05-19 21:22:07 -04:00
legop3 daa8942a29 overseer panel tweaking 2026-05-19 21:12:57 -04:00
legop3 8c2162d918 overseer voting stuff yay! 2026-05-19 21:08:06 -04:00
legop3 ab32b0416c always show tools on spectator. rename neato locate to sound 2026-05-19 20:51:10 -04:00
legop3 3ce2bdc541 fix messed up typing row now that message row changed 2026-05-19 19:22:20 -04:00
legop3 bac957dfd6 promp 2026-05-19 19:18:24 -04:00
legop3 2ad6f5e6d4 promp 2026-05-19 18:48:33 -04:00
legop3 18c70b01c5 okay maybe 2026-05-19 18:45:49 -04:00
legop3 51d8c72ae8 ugh. 2026-05-19 18:41:55 -04:00
legop3 084dee4df9 raaahhhhhflkhdj 2026-05-19 18:28:48 -04:00
legop3 fb65926760 raaaaahhh 2026-05-19 18:26:44 -04:00
legop3 6c3ac6d22d chat line fix lole 2026-05-19 18:23:30 -04:00
legop3 4d179b4372 okay render tools above messages.. yay 2026-05-19 17:59:45 -04:00
legop3 b8d00e063f gagrarh 2026-05-19 17:57:14 -04:00
legop3 a109c49a5b tools dropdown in message row header 2026-05-19 17:52:51 -04:00
legop3 faac11e3fc promp 2026-05-19 17:49:03 -04:00
legop3 133ea3f6d2 args and no more emojis 2026-05-19 17:29:08 -04:00
legop3 f9e472c832 wording and overseer tool embeds 2026-05-19 16:51:02 -04:00
legop3 e432e3de79 replay panel tweaking.. 2026-05-19 16:13:10 -04:00
legop3 8a7be00bf6 acrashing 2026-05-18 00:03:26 -04:00
legop3 372c5dee70 scrole 2026-05-18 00:00:56 -04:00
legop3 135ee78a5d scroll fixing 2026-05-17 23:57:15 -04:00
legop3 84e5cbc787 horn scroll and lower limits!! 2026-05-17 23:51:43 -04:00
legop3 f9c4249086 Merge pull request #9 from legop3/manualdock
Manualdock
2026-05-17 23:35:50 -04:00
59 changed files with 1074 additions and 567 deletions
+9 -8
View File
@@ -1,11 +1,12 @@
1. add faster way for admins to login, like an invisible button in top left or something
2. add tool call embeds or something for the llm bot in discord, probably not in web ui
3. add discord bot typing thing for when someone requests a replay
4. change replay title for ones requested from discord, something other than "requester driving rover"
5. fix rover request spam queue cheat
6. enable scrolling on horn frequencies, turn down frequency limit to like 3500
7. make replays more instant, probably make segments shorter
1. fix replay UI so it doesnt save anything in cookie
1. FIX NO AUDIO DURING NOT YOUR TURN!!!
2. make sure audio forwarding doesnt stop when you switch tabs
3. add "reboot your own rover" feature
1.
4. add faster way for admins to login, like an invisible button in top left or something
5. add discord bot typing thing for when someone requests a replay
6. change replay title for ones requested from discord, something other than "requester driving rover"
7. fix rover request spam queue cheat
# relative pipe dreams:
1. VPS video forwarding
+10
View File
@@ -17,12 +17,14 @@ overseerControl:
enabled: false
observeOnly: true
alwaysRunModel: false
postToolsOnlyMessages: false
name: "The Overseer"
model: "qwen2.5:7b-instruct"
ollamaServer: "http://127.0.0.1:11434"
profileImageUrl: "https://example.com/overseer.png"
gateIntervalMs: 2000
heartbeatMs: 30000
postChatDelayMs: 20000
media:
# Base address for mediaMTX (scheme + host + optional port/path). The UI will always request
# http://<base>/<roverId>/whep
@@ -118,12 +120,20 @@ socials:
- id: "discord"
label: "Discord"
url: "https://discord.gg/your-invite"
icon: "FaDiscord"
color: "#5865F2"
- id: "kofi"
label: "Ko-fi"
url: "https://ko-fi.com/your-handle"
icon: "FaCoffee"
color: "#29ABE0"
- id: "wiki"
label: "Wiki"
url: "https://wiki.example.com"
icon: "FaBook"
color: "#475569"
- id: "throne"
label: "Throne"
url: "https://throne.me/yourname"
icon: "FaCrown"
color: "#334155"
+5 -1
View File
@@ -7,16 +7,20 @@ Identity:
- You are efficient and strategic, not random and not spammy.
Rules:
- Do NOT respond to something again if you have already responded to it.
- Each cycle is autonomous; do not wait for permission from metadata.
- Don't respond or use tools every cycle unless the context calls for it.
- `STATE_UPDATE`, `MEMORY_UPDATE`, and `tool_constraints` are context.
- Use tool calls for actions and memory updates.
- If an action is needed, call tools directly; do not describe tool calls in chat.
- NEVER put tool calls in your chat output.
- When a chaos opportunity exists, take one tool action this cycle instead of only commenting.
- Respect lock policies, cooldowns, and blocked tools.
- Do not invent tools.
- Ask a question only when a required action parameter is missing.
- Ignore obvious bot commands and bot responses in chat.
- Only talk in first person, do not narrate yourself.
- It is not possible for you to control the rovers.
- All rovers are female. Neato is nonbinary.
Chat style:
- Always use English.
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
+2 -2
View File
@@ -11,8 +11,8 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Roomba Rover" />
<title>Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-GngyFkpc.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-LJWnKvT4.css">
<script type="module" crossorigin src="/assets/index-CboMb02p.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BGy4iG0w.css">
</head>
<body>
<div id="root"></div>
@@ -100,6 +100,21 @@ function buildRoverCtxSnapshot(roverId) {
function buildMessage(socket, text, meta = {}) {
const roverId = meta.roverId || resolveRoverId(socket?.id);
const roverColor = meta.roverColor ?? resolveRoverColor(roverId);
const toolCalls = Array.isArray(meta.toolCalls)
? meta.toolCalls
.map((entry) => {
if (!entry || typeof entry !== 'object') return null;
return {
tool: String(entry.tool || '').trim() || 'unknown',
status: String(entry.status || '').trim() || 'unknown',
args: entry.args && typeof entry.args === 'object' ? entry.args : {},
result: entry.result && typeof entry.result === 'object' ? entry.result : null,
error: entry.error ? String(entry.error) : null,
durationMs: Number.isFinite(entry.durationMs) ? Math.max(0, Math.round(entry.durationMs)) : null,
};
})
.filter(Boolean)
: null;
return {
id: uuidv4(),
ts: Date.now(),
@@ -119,6 +134,7 @@ function buildMessage(socket, text, meta = {}) {
profileImage: normalizeProfileImageUrl(meta.profileImage),
roverCtx: meta.roverCtx || null,
text,
toolCalls,
tts: meta.tts || null,
bot: Boolean(meta.bot),
};
+5 -2
View File
@@ -1,7 +1,7 @@
// Chat Service Orchestrator
// Purpose: Composes chat submodules into the public service API and boots socket/event wiring.
// Scope: Keeps external chat contracts stable while delegating logic to focused modules.
const { history } = require('./state');
const { history, clearHistory } = require('./state');
const { normalizeUserText } = require('./contentFilters');
const { buildMessage, buildTypingPayload } = require('./contextBuilders');
const { broadcastMessage, getRecentMessages } = require('./broadcast');
@@ -11,7 +11,8 @@ const { registerChatSocketHooks } = require('./socketHooks');
function sendSystemMessage(text, options = {}) {
const normalized = normalizeUserText(text);
const clean = normalized.trim();
if (!clean) return null;
const hasToolCalls = Array.isArray(options.toolCalls) && options.toolCalls.length > 0;
if (!clean && !hasToolCalls) return null;
const safe = clean;
const message = buildMessage(null, safe, {
nickname: String(options.nickname || 'The Overseer'),
@@ -19,6 +20,7 @@ function sendSystemMessage(text, options = {}) {
fromDiscord: false,
bot: options.bot !== false,
profileImage: options.profileImage || null,
toolCalls: hasToolCalls ? options.toolCalls : null,
});
broadcastMessage(message);
return message;
@@ -35,4 +37,5 @@ module.exports = {
buildTypingPayload,
sendSystemMessage,
getRecentMessages,
clearHistory,
};
+7
View File
@@ -40,6 +40,12 @@ function setLastAccessNoticeAt(ts) {
lastAccessNoticeAt = ts;
}
function clearHistory() {
history.length = 0;
lastMessageBySocket.clear();
typingBySocket.clear();
}
module.exports = {
rateBuckets,
history,
@@ -50,4 +56,5 @@ module.exports = {
getRecentMessages,
getLastAccessNoticeAt,
setLastAccessNoticeAt,
clearHistory,
};
@@ -3,6 +3,22 @@
// Scope: Handles inbound Discord messages plus outbound webhook and typing relay.
const { WebhookClient } = require('discord.js');
function summarizeToolCall(entry = {}) {
const tool = String(entry?.tool || 'unknown');
const status = String(entry?.status || 'unknown').toLowerCase();
const argsText = JSON.stringify(entry?.args && typeof entry.args === 'object' ? entry.args : {});
if (status === 'ok') return `ok ${tool} args=${argsText}`;
if (status === 'blocked') return `blocked ${tool} args=${argsText} - ${String(entry?.error || 'blocked')}`;
if (status === 'error') return `error ${tool} args=${argsText} - ${String(entry?.error || 'failed')}`;
return `unknown ${tool} args=${argsText}`;
}
function formatToolCallsCodeBlock(toolCalls = []) {
const rows = (toolCalls || []).map((entry) => summarizeToolCall(entry));
if (!rows.length) return '';
return `\`\`\`txt\nTool calls:\n${rows.join('\n')}\n\`\`\``;
}
function createChatBridgeHandlers(deps) {
const {
logger,
@@ -48,7 +64,15 @@ function createChatBridgeHandlers(deps) {
const guildConfigs = listGuildConfigs();
if (!guildConfigs.length) return;
const text = payload.text?.length > 1900 ? `${payload.text.slice(0, 1897)}...` : payload.text;
const baseText = String(payload.text || '');
const toolCalls = Array.isArray(payload.toolCalls) ? payload.toolCalls : [];
const toolsBlock = formatToolCallsCodeBlock(toolCalls);
let text = baseText;
if (toolsBlock) {
text = text ? `${text}\n\n${toolsBlock}` : `${toolsBlock}`;
}
if (text.length > 1900) text = `${text.slice(0, 1897)}...`;
if (!text.trim()) return;
const username = formatWebhookUsername(payload);
const avatarURL = payload.profileImage || (payload.fromDiscord ? payload.discordUserAvatarUrl || null : client.user?.displayAvatarURL?.({ extension: 'png', size: 128 }) || null);
const typingId = getTypingId(payload);
@@ -4,6 +4,7 @@ const PROMPT_PATH = path.join(__dirname, '..', '..', '..', 'prompts', 'overseer_
const DEFAULT_NAME = 'The Overseer';
const DEFAULT_GATE_INTERVAL_MS = 2000;
const DEFAULT_HEARTBEAT_MS = 30000;
const DEFAULT_POST_CHAT_DELAY_MS = 20000;
const MIN_INTERVAL_MS = 250;
const MAX_RUN_HISTORY = 100;
const MAX_CHAT_CONTEXT = 12;
@@ -19,6 +20,7 @@ module.exports = {
DEFAULT_NAME,
DEFAULT_GATE_INTERVAL_MS,
DEFAULT_HEARTBEAT_MS,
DEFAULT_POST_CHAT_DELAY_MS,
MAX_RUN_HISTORY,
MAX_CHAT_CONTEXT,
MAX_BOT_CONTEXT,
@@ -60,16 +60,28 @@ function buildConversation({ recentMessages, name }) {
return messages;
}
function buildModelMessages({ systemPrompt, stateUpdate, memorySummary, conversationMessages, availableTools, blockedTools }) {
function buildModelMessages({
systemPrompt,
stateUpdate,
memorySummary,
recentEvents,
conversationMessages,
availableTools,
blockedTools,
}) {
const messages = [];
messages.push({ role: 'system', content: systemPrompt });
const metadataSections = [];
metadataSections.push(`STATE_UPDATE\n${stateUpdate}`);
if (memorySummary) metadataSections.push(`MEMORY_UPDATE\n${memorySummary}`);
metadataSections.push(
`tool_constraints:\n${blockedTools.map((entry) => `- blocked: ${entry.tool} reason=${entry.reason}`).join('\n') || '- none'}`,
);
messages.push({ role: 'user', content: metadataSections.join('\n\n') });
messages.push({ role: 'system', content: `ROOM_SNAPSHOT\n${stateUpdate}` });
if (memorySummary) {
messages.push({ role: 'system', content: `MEMORY_SUMMARY\n${memorySummary}` });
}
if (recentEvents) {
messages.push({ role: 'system', content: `RECENT_EVENTS\n${recentEvents}` });
}
messages.push({
role: 'system',
content: `TOOL_CONSTRAINTS\n${blockedTools.map((entry) => `- blocked: ${entry.tool} reason=${entry.reason}`).join('\n') || '- none'}`,
});
(conversationMessages || []).forEach((message) => {
if (!message || !message.role || !message.content) return;
messages.push(message);
@@ -5,6 +5,7 @@ const logger = require('../../globals/logger').child('overseerControl');
const { loadConfig } = require('../../helpers/configLoader');
const { getRole, roleEvents } = require('../roleService');
const { getMode, MODES, modeEvents } = require('../modeManager');
const { verificationEvents } = require('../verificationService');
const homeAssistantService = require('../homeAssistantService');
const neatoService = require('../neatoService');
const liftService = require('../liftService');
@@ -14,11 +15,13 @@ const { getState: getNeatoState, neatoEvents } = neatoService;
const { getState: getLiftState, liftEvents } = liftService;
const roverManager = require('../roverManager');
const { getRecentMessages, sendSystemMessage } = require('../chatService');
const { subscribe } = require('../eventBus');
const {
PROMPT_PATH,
DEFAULT_NAME,
DEFAULT_GATE_INTERVAL_MS,
DEFAULT_HEARTBEAT_MS,
DEFAULT_POST_CHAT_DELAY_MS,
MAX_RUN_HISTORY,
MAX_CHAT_CONTEXT,
MAX_BOT_CONTEXT,
@@ -38,7 +41,9 @@ const model = String(overseerConfig.model || '').trim();
const ollamaUrl = String(overseerConfig.ollamaUrl || overseerConfig.ollamaServer || '').trim();
const gateIntervalMs = normalizeMs(Number(overseerConfig.gateIntervalMs), DEFAULT_GATE_INTERVAL_MS);
const heartbeatMs = normalizeMs(Number(overseerConfig.heartbeatMs), DEFAULT_HEARTBEAT_MS);
const postChatDelayMs = normalizeMs(Number(overseerConfig.postChatDelayMs), DEFAULT_POST_CHAT_DELAY_MS);
const alwaysRunModel = Boolean(overseerConfig.alwaysRunModel);
const postToolsOnlyMessages = Boolean(overseerConfig.postToolsOnlyMessages);
const profileImageUrl = String(overseerConfig.profileImageUrl || '').trim() || null;
const ollamaClient = ollamaUrl ? new Ollama({ host: ollamaUrl }) : null;
@@ -52,6 +57,7 @@ const runtime = {
runHistory: [],
liveToolCalls: [],
memoryStore: loadMemory(),
contextResetAt: Date.now(),
};
let status = {
@@ -63,7 +69,9 @@ let status = {
promptPath: PROMPT_PATH,
gateIntervalMs,
heartbeatMs,
postChatDelayMs,
alwaysRunModel,
postToolsOnlyMessages,
running: false,
inFlight: false,
phase: 'idle',
@@ -89,6 +97,7 @@ let status = {
lastLiveToolCalls: null,
lastOutcome: null,
lastReason: null,
voteStatus: null,
lastError: null,
lastErrorDetails: null,
lastFailedAt: null,
@@ -98,6 +107,44 @@ let status = {
updatedAt: Date.now(),
};
function buildVoteStatus() {
const sockets = Array.from(io.sockets.sockets.values());
let yesCount = 0;
let noCount = 0;
const votesByIdentity = new Map();
const isEligibleVoter = (socket) => getRole(socket) !== 'spectator';
sockets.forEach((socket) => {
if (!isEligibleVoter(socket)) return;
const identityKey = String(socket?.data?.cookieUserId || '').trim() || `socket:${socket.id}`;
const pref = typeof socket?.data?.overseerEnabled === 'boolean' ? socket.data.overseerEnabled : true;
const prev = votesByIdentity.get(identityKey);
if (typeof prev === 'boolean') {
// If one tab says "no", treat that user as "no" to avoid accidental override by stale tabs.
votesByIdentity.set(identityKey, prev && pref);
return;
}
votesByIdentity.set(identityKey, pref);
});
votesByIdentity.forEach((pref) => {
if (pref) yesCount += 1;
else noCount += 1;
});
const eligibleCount = votesByIdentity.size;
const onlineCount = yesCount + noCount;
const gatePassed = eligibleCount > 0 && yesCount > noCount;
return {
yesCount,
noCount,
onlineCount,
eligibleCount,
gatePassed,
running: Boolean(enabled && gatePassed && getMode() !== MODES.LOCKDOWN),
};
}
function updateStatus(patch = {}) {
status = { ...status, ...patch, updatedAt: Date.now() };
const payload = buildAdminState(status, runtime.runHistory);
@@ -161,6 +208,7 @@ function computeTriggerReason() {
if (alwaysRunModel) return 'loop_tick';
const recent = getRecentMessages(1, { includeSystem: false });
const last = recent[recent.length - 1];
if (last && Number(last.ts || 0) < runtime.contextResetAt) return null;
if (last && Date.now() - Number(last.ts || 0) < 5000) {
const txt = String(last.text || '').toLowerCase();
if (txt.includes(name.toLowerCase()) || txt.includes('overseer') || txt.includes('bot')) return 'direct_address';
@@ -207,6 +255,58 @@ function normalizeChatDraft(text) {
return next;
}
function summarizeResult(result) {
if (!result || typeof result !== 'object') return null;
if (Object.prototype.hasOwnProperty.call(result, 'ok')) return { ok: Boolean(result.ok) };
return null;
}
function buildToolCallFeedEntries(requestedActions = [], actionResults = []) {
const resultsByTool = new Map();
(actionResults || []).forEach((entry) => {
if (!entry || entry.kind !== 'tool') return;
const key = String(entry.tool || '');
if (!key) return;
if (!resultsByTool.has(key)) resultsByTool.set(key, []);
resultsByTool.get(key).push(entry);
});
return (requestedActions || []).map((action) => {
const tool = String(action?.tool || '').trim() || 'unknown';
const bucket = resultsByTool.get(tool) || [];
const resultEntry = bucket.length ? bucket.shift() : null;
const ok = Boolean(resultEntry?.ok);
const errText = resultEntry?.error ? String(resultEntry.error) : '';
const status = resultEntry
? ok
? 'ok'
: errText.includes('blocked') || errText.includes('unavailable')
? 'blocked'
: 'error'
: 'started';
return {
tool,
status,
args: action?.args && typeof action.args === 'object' ? action.args : {},
result: summarizeResult(resultEntry?.result),
error: errText || null,
};
});
}
function buildRecentEventsSummary() {
const events = (runtime.liveToolCalls || [])
.filter((entry) => entry && (entry.phase === 'ok' || entry.phase === 'error' || entry.phase === 'blocked'))
.slice(-3)
.map((entry) => {
const tool = String(entry.tool || 'unknown');
const phase = String(entry.phase || 'unknown');
const err = entry.error ? ` error=${String(entry.error).slice(0, 60)}` : '';
return `- tool=${tool} phase=${phase}${err}`;
});
if (!events.length) return '- none';
return events.join('\n');
}
async function runDecision(triggerReason) {
const runId = runtime.tickCount;
updateStatus({ phase: 'context_build', currentRunId: runId, lastTriggerReason: triggerReason, lastError: null, lastErrorDetails: null });
@@ -221,6 +321,7 @@ async function runDecision(triggerReason) {
const toolState = buildToolState({ mode, homeAssistantState, neatoState, liftState });
const recentConversation = getRecentMessages(MAX_CHAT_CONTEXT + MAX_BOT_CONTEXT + 20, { includeSystem: true })
.filter((entry) => Number(entry?.ts || 0) >= runtime.contextResetAt)
.filter((entry) => {
if (!entry?.roverId) return true;
return roverManager.canReplayRoverId(entry.roverId);
@@ -233,6 +334,7 @@ async function runDecision(triggerReason) {
systemPrompt,
stateUpdate,
memorySummary: summarizeMemory(runtime.memoryStore),
recentEvents: buildRecentEventsSummary(),
conversationMessages,
availableTools: toolState.available,
blockedTools: toolState.blocked,
@@ -276,15 +378,11 @@ async function runDecision(triggerReason) {
const actionResults = [];
const requestedActions = toolCalls;
let postedChat = false;
let outcome = observeOnly ? 'observed' : 'executed';
const reason = observeOnly ? 'observe-only mode' : null;
if (!observeOnly) {
if ((decision === 'CHAT' || decision === 'ACTION+CHAT') && chatDraft) {
sendSystemMessage(chatDraft, { nickname: name, bot: true, profileImage: profileImageUrl });
actionResults.push({ kind: 'chat', ok: true });
}
if (decision === 'ACTION' || decision === 'ACTION+CHAT') {
for (const action of requestedActions) {
pushLiveToolCall({ phase: 'start', tool: action.tool, args: action.args });
@@ -315,6 +413,19 @@ async function runDecision(triggerReason) {
}
}
}
const toolCallFeed = buildToolCallFeedEntries(requestedActions, actionResults);
if (toolCallFeed.length > 0) {
if ((decision === 'CHAT' || decision === 'ACTION+CHAT') && chatDraft) {
sendSystemMessage(chatDraft, { nickname: name, bot: true, profileImage: profileImageUrl, toolCalls: toolCallFeed });
postedChat = true;
} else if (postToolsOnlyMessages) {
sendSystemMessage('', { nickname: name, bot: true, profileImage: profileImageUrl, toolCalls: toolCallFeed });
}
} else if ((decision === 'CHAT' || decision === 'ACTION+CHAT') && chatDraft) {
sendSystemMessage(chatDraft, { nickname: name, bot: true, profileImage: profileImageUrl });
postedChat = true;
}
}
updateStatus({
@@ -345,6 +456,8 @@ async function runDecision(triggerReason) {
generationMs,
blockedTools: toolState.blocked,
});
return { postedChat };
}
async function tick() {
@@ -352,12 +465,16 @@ async function tick() {
runtime.inFlight = true;
updateStatus({ inFlight: true, tickCount: runtime.tickCount, lastTickAt: Date.now(), phase: 'gate_check' });
let nextDelayMs = gateIntervalMs;
try {
const triggerReason = computeTriggerReason();
if (!triggerReason) {
updateStatus({ phase: 'idle', lastOutcome: 'skipped', lastReason: 'gate not triggered' });
} else {
await runDecision(triggerReason);
const runResult = await runDecision(triggerReason);
if (runResult?.postedChat) {
nextDelayMs = postChatDelayMs;
}
}
} catch (err) {
const failure = buildFailureInfo(err);
@@ -372,8 +489,8 @@ async function tick() {
} finally {
runtime.inFlight = false;
if (status.running) {
updateStatus({ inFlight: false, currentRunId: null, phase: 'idle', nextRunAt: Date.now() + gateIntervalMs });
runtime.timer = setTimeout(tick, gateIntervalMs);
updateStatus({ inFlight: false, currentRunId: null, phase: 'idle', nextRunAt: Date.now() + nextDelayMs });
runtime.timer = setTimeout(tick, nextDelayMs);
} else {
updateStatus({ inFlight: false, currentRunId: null, nextRunAt: null });
}
@@ -385,13 +502,41 @@ function emitStateToSocket(socket) {
socket.emit('overseer:state', buildAdminState(status, runtime.runHistory));
}
function clearHistory() {
function clearHistory(reason = 'admin requested clear history', options = {}) {
const resetPersistentMemory = options?.resetPersistentMemory !== false;
const sendConfirmation = options?.sendConfirmation === true;
runtime.contextResetAt = Date.now();
runtime.runHistory = [];
runtime.liveToolCalls = [];
runtime.generationCount = 0;
runtime.generationTotalMs = 0;
runtime.memoryStore = saveMemory(createDefaultMemory());
updateStatus({ lastReason: 'admin requested clear history', lastOutcome: 'cleared', lastLiveToolCalls: [] });
runtime.lastModelAt = 0;
if (resetPersistentMemory) {
runtime.memoryStore = saveMemory(createDefaultMemory());
}
updateStatus({
phase: 'idle',
currentRunId: null,
lastSystemPrompt: null,
lastStateUpdate: null,
lastTranscript: null,
lastAvailableTools: null,
lastBlockedTools: null,
lastModelMessages: null,
lastModelInputAt: null,
lastModelOutputAt: null,
lastModelRawOutput: null,
lastDecision: null,
lastChatDraft: null,
lastRequestedActions: null,
lastActionResults: null,
lastLiveToolCalls: [],
lastOutcome: 'cleared',
lastReason: reason,
});
if (sendConfirmation) {
sendSystemMessage('Context cleared.', { nickname: name, bot: true, profileImage: profileImageUrl });
}
}
function stopScheduler(reason = 'paused') {
@@ -416,8 +561,29 @@ function startScheduler(reason = null) {
runtime.timer = setTimeout(tick, gateIntervalMs);
}
function evaluateSchedulerGate(reason = 'gate reevaluated') {
const voteStatus = buildVoteStatus();
const runningAllowed = Boolean(enabled && voteStatus.gatePassed && getMode() !== MODES.LOCKDOWN);
updateStatus({ voteStatus });
if (!enabled) {
stopScheduler('overseerControl.enabled is false');
return;
}
if (getMode() === MODES.LOCKDOWN) {
stopScheduler('paused during lockdown');
return;
}
if (!voteStatus.gatePassed) {
stopScheduler('paused by user vote');
return;
}
startScheduler(reason);
}
io.on('connection', (socket) => {
emitStateToSocket(socket);
evaluateSchedulerGate('online vote update');
socket.on('disconnect', () => evaluateSchedulerGate('online vote update'));
socket.on('overseer:control', ({ controls } = {}, cb = () => {}) => {
if (!isAdminRole(getRole(socket))) return cb({ error: 'Not authorized' });
const action = controls?.action || null;
@@ -429,7 +595,17 @@ io.on('connection', (socket) => {
});
});
roleEvents.on('change', ({ socket }) => emitStateToSocket(socket));
roleEvents.on('change', ({ socket }) => {
emitStateToSocket(socket);
evaluateSchedulerGate('online vote update');
});
subscribe('chat:message', ({ payload } = {}) => {
const text = String(payload?.text || '').trim();
if (text !== 'CLEAR') return;
if (payload?.bot) return;
clearHistory('chat CLEAR command', { resetPersistentMemory: false, sendConfirmation: true });
});
verificationEvents.on('change', () => evaluateSchedulerGate('online vote update'));
homeAssistantEvents.on('update', () => updateStatus({ phase: status.phase }));
neatoEvents.on('update', () => updateStatus({ phase: status.phase }));
liftEvents.on('update', () => updateStatus({ phase: status.phase }));
@@ -441,7 +617,7 @@ modeEvents.on('change', (mode) => {
logger.info('overseerControl paused due to lockdown mode');
return;
}
startScheduler(observeOnly ? 'observe-only mode' : null);
evaluateSchedulerGate(observeOnly ? 'observe-only mode' : null);
});
if (!enabled) {
@@ -452,9 +628,11 @@ if (!enabled) {
stopScheduler('paused during lockdown');
logger.info('overseerControl paused on startup due to lockdown mode');
} else {
startScheduler(observeOnly ? 'observe-only mode' : null);
evaluateSchedulerGate(observeOnly ? 'observe-only mode' : null);
logger.info('overseerControl enabled', { model, ollamaUrl, gateIntervalMs, heartbeatMs, observeOnly });
}
}
module.exports = {};
module.exports = {
getVoteStatus: () => status.voteStatus || buildVoteStatus(),
};
@@ -1,7 +1,7 @@
module.exports = {
id: 'neato_locate',
signature: 'neato_locate()',
description: 'Play Neato locate/chime action.',
id: 'neato_play_sound',
signature: 'neato_play_sound()',
description: 'Play a sound/chime on the Neato robot.',
parameters: { type: 'object', properties: {}, additionalProperties: false },
availability(ctx = {}) {
const mode = String(ctx.mode || '');
@@ -13,6 +13,7 @@ const { getRoomCameras, roomCameraEvents } = require('../roomCameraService');
const { getState: getHomeAssistantState, homeAssistantEvents } = require('../homeAssistantService');
const { getState: getNeatoState, neatoEvents } = require('../neatoService');
const { getState: getLiftState, liftEvents } = require('../liftService');
const { getVoteStatus: getOverseerVoteStatus } = require('../overseerControlService');
const { getNickname, nicknameEvents } = require('../nicknameService');
const {
getVerificationStateForSocket,
@@ -66,6 +67,7 @@ function buildUserEntry(socket) {
}
function buildSession(socket) {
const overseerVote = getOverseerVoteStatus();
const users = Array.from(io.sockets.sockets.values())
.map((sock) => buildUserEntry(sock))
.filter(Boolean)
@@ -124,6 +126,10 @@ function buildSession(socket) {
audioForward: getAudioForwardState(),
audioLevels: getAudioLevels(),
buttonBox: getButtonBoxState(),
overseerVote: {
...overseerVote,
preference: typeof socket?.data?.overseerEnabled === 'boolean' ? socket.data.overseerEnabled : true,
},
};
}
@@ -111,11 +111,18 @@ function identifySocket(socket, payload = {}) {
}
}
if (Object.prototype.hasOwnProperty.call(payload, 'overseerEnabled')) {
data.overseerEnabled = Boolean(payload.overseerEnabled);
} else if (typeof data.overseerEnabled !== 'boolean') {
data.overseerEnabled = true;
}
const verification = reevaluateSocketVerification(socket);
const deterrence = reevaluateSocketDeterrence(socket);
emitChange('identify', { socketId: socket.id });
return {
cookieUserId: data.cookieUserId,
overseerEnabled: Boolean(data.overseerEnabled),
isVerified: verification.isVerified,
isDeterred: deterrence.isDeterred,
reason: verification.reason,
@@ -97,6 +97,7 @@ function createVerificationFlow(deps) {
return {
cookieUserId: normalizeCookieUserId(data.cookieUserId) || null,
nickname: getNickname(socket) || null,
overseerEnabled: typeof data.overseerEnabled === 'boolean' ? data.overseerEnabled : true,
};
}
+12 -2
View File
@@ -22,6 +22,8 @@ import ModeGateOverlay from './components/ModeGateOverlay/index.jsx';
import HomeAssistantControls from './components/HomeAssistantControls/index.jsx';
import TurnAlertListener from './components/TurnAlertListener/index.jsx';
import RawUserPilePanel from './components/RawUserPilePanel/index.jsx';
import OverseerPreferencePanel from './components/OverseerPreferencePanel/index.jsx';
import SocialButtonsGrid from './components/SocialButtonsGrid/index.jsx';
import ChatPanel from './components/ChatPanel/index.jsx';
import FullscreenPrompt from './components/FullscreenPrompt/index.jsx';
import FloatingFullscreenButton from './components/FloatingFullscreenButton/index.jsx';
@@ -141,8 +143,16 @@ function MobileFeatureTabs({
<TabPanels>
<TabPanel id="chat">
<div className="space-y-0.5">
<ChatPanel />
<RawUserPilePanel />
<ChatPanel nicknameLayout="stacked" />
<div className="space-y-0.5">
<div className="grid gap-0.5 md:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)]">
<SocialButtonsGrid />
</div>
<div className="grid gap-0.5 grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<OverseerPreferencePanel />
<RawUserPilePanel hideNicknameForm />
</div>
</div>
</div>
</TabPanel>
<TabPanel id="vip" keepMounted>
@@ -7,6 +7,7 @@ import { useSocket } from '../../context/SocketContext.jsx';
import { useSettingsNamespace } from '../../settings/index.js';
import { AUDIO_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
import ButtonBoxTile from '../ButtonBoxTile/index.jsx';
import CardFrame from '../CardFrame/index.jsx';
const FLASH_MS = 420;
const REWARD_FLASH_MS = 1200;
@@ -126,10 +127,7 @@ export default function ButtonBoxPanel() {
}, [effectiveAlertVolume, socket]);
return (
<section className="panel-section space-y-0.5 text-base">
<header className="panel-muted text-center text-sm">
<p>Button Box</p>
</header>
<CardFrame title="Button Box" bodyClassName="space-y-0.5 text-base">
<div className="grid grid-cols-4 gap-0.5">
{buttons.map((button) => {
const id = Number(button.id);
@@ -155,6 +153,6 @@ export default function ButtonBoxPanel() {
);
})}
</div>
</section>
</CardFrame>
);
}
+89
View File
@@ -0,0 +1,89 @@
import { useSessionSelector } from '../../context/SessionContext.jsx';
// Utilities
function cx(...values) {
return values.filter(Boolean).join(' ');
}
// Color helpers
function hexToRgb(hex) {
const raw = String(hex || '').trim();
const normalized = raw.startsWith('#') ? raw.slice(1) : raw;
if (!/^[0-9a-fA-F]{3}$|^[0-9a-fA-F]{6}$/.test(normalized)) return null;
const expanded =
normalized.length === 3
? normalized
.split('')
.map((ch) => ch + ch)
.join('')
: normalized;
return {
r: Number.parseInt(expanded.slice(0, 2), 16),
g: Number.parseInt(expanded.slice(2, 4), 16),
b: Number.parseInt(expanded.slice(4, 6), 16),
};
}
function rgba(rgb, alpha) {
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${alpha})`;
}
// Component
export default function CardFrame({
title = '',
meta = null,
actions = null,
hideHeader = false,
className = '',
headerClassName = '',
bodyClassName = '',
fillHeight = false,
clipOverflow = true,
children,
}) {
const showHeader = !hideHeader && (title || meta != null || actions);
const ownRoverColor = useSessionSelector((state) => {
const roverId = String(state.session?.assignment?.roverId || '').trim();
if (!roverId) return null;
const roster = Array.isArray(state.session?.roster) ? state.session.roster : [];
const rover = roster.find((entry) => String(entry?.id) === roverId);
return rover?.color || null;
});
const accentRgb = hexToRgb(ownRoverColor);
const cardStyle = accentRgb ? { borderColor: rgba(accentRgb, 0.35) } : undefined;
const headerStyle = accentRgb
? {
backgroundImage: `linear-gradient(90deg, rgba(23,23,23,0.96) 0%, rgba(38,38,38,0.94) 58%, ${rgba(accentRgb, 0.18)} 100%)`,
}
: undefined;
return (
<section
className={cx(
'panel-section border border-neutral-500/60 bg-neutral-900/95 shadow-[0_1px_0_rgba(255,255,255,0.05)_inset,0_10px_24px_rgba(0,0,0,0.28)]',
clipOverflow ? 'overflow-hidden' : 'overflow-visible',
fillHeight && 'flex h-full min-h-0 flex-col',
className,
)}
style={cardStyle}
>
{showHeader ? (
// Header row
<header
className={cx(
'flex items-center justify-between gap-0.5 border-b border-neutral-500/50 bg-gradient-to-r from-neutral-800 via-neutral-700 to-neutral-600 px-0.5 py-0.5',
headerClassName,
)}
style={headerStyle}
>
<div className="flex min-w-0 items-center gap-0.5">
{title ? <p className="m-0 text-[0.78rem] font-semibold leading-none text-neutral-50">{title}</p> : null}
{meta != null ? <span className="text-[0.68rem] font-medium leading-none text-neutral-200">{meta}</span> : null}
</div>
{actions ? <div className="flex flex-wrap items-center justify-end gap-0.5">{actions}</div> : null}
</header>
) : null}
<div className={cx(fillHeight && 'flex flex-1 min-h-0 flex-col', bodyClassName)}>{children}</div>
</section>
);
}
+56 -11
View File
@@ -1,8 +1,10 @@
// Chat Message Row
// Purpose: Defines the Chat Message Row module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { useState } from 'react';
import { FaDiscord } from 'react-icons/fa';
import { roverBadgeStyle } from '../../lib/roverColor.js';
import { useSessionSelector } from '../../context/SessionContext.jsx';
function roleColors(role) {
switch (role) {
@@ -77,7 +79,7 @@ function ProfileAvatar({ imageUrl, label }) {
);
}
export function ChatIdentity({ message }) {
export function ChatIdentity({ message, toolsToggle = null }) {
const discordLabel = message.fromDiscord
? `${message.discordGuildName || 'Discord'} · ${displayName(message)}`
: null;
@@ -112,17 +114,18 @@ export function ChatIdentity({ message }) {
{message.roverId}
</span>
)}
{toolsToggle}
</span>
);
}
function chatRowClass(message) {
if (isBotMessage(message)) {
return 'surface-muted flex items-center gap-0.5 border border-emerald-500/40 bg-emerald-900/15 text-sm';
return 'surface-muted flex flex-col gap-0.5 border border-emerald-500/40 bg-emerald-900/15 text-sm';
}
const isAdmin =
message.role === 'admin' || message.role === 'lockdown' || message.role === 'lockdown-admin';
return `surface-muted flex items-center gap-0.5 text-sm ${
return `surface-muted flex flex-col gap-0.5 text-sm ${
isAdmin
? 'border border-amber-400/30'
: message.fromDiscord
@@ -133,16 +136,58 @@ function chatRowClass(message) {
export default function ChatMessageRow({ message }) {
const isBot = isBotMessage(message);
const role = useSessionSelector((state) => state.session?.role || null);
const isSpectator = role === 'spectator';
const [open, setOpen] = useState(false);
const isOpen = isSpectator || open;
const toolCalls = Array.isArray(message?.toolCalls) ? message.toolCalls : [];
const hasText = Boolean(String(message?.text || '').trim());
const toolsToggle =
toolCalls.length > 0 ? (
<button
type="button"
className="shrink-0 rounded border border-slate-600/70 bg-slate-800/60 px-1 py-[1px] text-[0.65rem] text-slate-200 hover:bg-slate-700/70"
onClick={() => {
if (isSpectator) return;
setOpen((v) => !v);
}}
>
{isOpen ? '▼' : '▶'} Tools ({toolCalls.length})
</button>
) : null;
return (
<div className={chatRowClass(message)}>
<span
className={`min-w-0 flex-1 break-words leading-tight whitespace-pre-wrap ${isBot ? 'text-emerald-100' : 'text-slate-100'}`}
>
<ChatIdentity message={message} /> {message.text}
</span>
<span className="ml-auto shrink-0 text-[0.65rem] text-slate-400/60">
{formatTime(message.ts)}
</span>
<div className="flex w-full items-start gap-0.5">
<span
className={`min-w-0 flex-1 break-words leading-tight whitespace-pre-wrap ${isBot ? 'text-emerald-100' : 'text-slate-100'}`}
>
<ChatIdentity message={message} toolsToggle={toolsToggle} />
{!isOpen && hasText ? ` ${message.text}` : ''}
</span>
<span className="shrink-0 text-[0.65rem] text-slate-400/60">
{formatTime(message.ts)}
</span>
</div>
{isOpen && toolCalls.length > 0 ? (
<div className="w-full rounded border border-slate-700/70 bg-slate-900/70 p-0.5 text-[0.68rem] text-slate-200">
{toolCalls.map((entry, idx) => {
const status = String(entry?.status || 'unknown');
const statusLabel = status === 'ok' ? 'ok' : status === 'blocked' ? 'blocked' : status === 'error' ? 'error' : 'unknown';
const argsText = JSON.stringify(entry?.args && typeof entry.args === 'object' ? entry.args : {});
return (
<div key={`${entry?.tool || 'tool'}-${idx}`} className="mb-0.5 last:mb-0">
<div>{statusLabel} {entry?.tool || 'unknown'} args={argsText}</div>
{entry?.error ? <div className="text-rose-300">error: {String(entry.error)}</div> : null}
</div>
);
})}
</div>
) : null}
{isOpen && hasText ? (
<div className={`w-full break-words leading-tight whitespace-pre-wrap ${isBot ? 'text-emerald-100' : 'text-slate-100'}`}>
{message.text}
</div>
) : null}
</div>
);
}
+27 -6
View File
@@ -7,6 +7,8 @@ import { useSessionSelector } from '../../context/SessionContext.jsx';
import { useSettingsNamespace } from '../../settings/index.js';
import ChatMessageRow from '../ChatMessageRow/index.jsx';
import ChatTypingRow from '../ChatTypingRow/index.jsx';
import CardFrame from '../CardFrame/index.jsx';
import NicknameForm from '../NicknameForm/index.jsx';
const FLITE_VOICES = ['kal', 'rms', 'slt', 'ksp', 'bdl'];
const ESPEAK_PITCHES = Array.from({ length: 10 }, (_, idx) => idx * 10);
@@ -16,6 +18,8 @@ export default function ChatPanel({
hideSpectatorNotice = false,
fillHeight = false,
allowSpectatorInput = false,
title = 'Chat and TTS',
nicknameLayout = 'inline',
}) {
const role = useSessionSelector((state) => state.session?.role || null);
const currentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId || null);
@@ -103,9 +107,16 @@ export default function ChatPanel({
}
const listClass = fillHeight ? 'flex-1 min-h-0 overflow-y-auto' : 'h-48 overflow-y-auto';
const isStackedNickname = nicknameLayout === 'stacked';
return (
<section className={`panel-section space-y-0.5 text-base ${fillHeight ? 'flex h-full flex-col overflow-hidden' : ''}`}>
<CardFrame
title={title}
hideHeader={!title}
fillHeight={fillHeight}
bodyClassName="space-y-0.5 text-base"
>
<div className={`surface overflow-y-auto space-y-0.5 px-0 ${listClass}`} ref={listRef}>
{sorted.length === 0 && typingRows.length === 0 ? (
<p className="text-sm text-slate-500">No messages yet.</p>
@@ -117,9 +128,15 @@ export default function ChatPanel({
))}
</div>
{!hideInput && (
<form className="flex flex-wrap items-stretch gap-0.5" onSubmit={handleSend}>
<form
className={`flex items-stretch gap-0.5 ${isStackedNickname ? 'flex-col' : 'flex-wrap'}`}
onSubmit={handleSend}
>
<div className={isStackedNickname ? 'w-full' : 'w-[9rem] sm:w-[10rem] shrink-0'}>
<NicknameForm compact />
</div>
<input
className="field-input flex-1 min-w-[10rem]"
className={`field-input ${isStackedNickname ? 'w-full min-w-0' : 'flex-1 min-w-[10rem]'}`}
value={draft}
onChange={(e) => {
const next = e.target.value;
@@ -146,7 +163,11 @@ export default function ChatPanel({
disabled={!canChat}
/>
{ttsSupported && (
<div className="flex flex-wrap items-center gap-0.5 basis-full sm:basis-auto">
<div
className={`flex flex-wrap items-center gap-0.5 ${
isStackedNickname ? 'w-full' : 'basis-full sm:basis-auto'
}`}
>
<label className="flex items-center gap-0.5 text-xs text-slate-300">
<input
type="checkbox"
@@ -206,12 +227,12 @@ export default function ChatPanel({
<button
type="submit"
disabled={!canChat || sending}
className="button-dark h-full disabled:opacity-50 self-stretch"
className={`button-dark disabled:opacity-50 ${isStackedNickname ? 'w-full h-8' : 'h-full self-stretch'}`}
>
{sending ? '...' : 'Send'}
</button>
</form>
)}
</section>
</CardFrame>
);
}
+1 -1
View File
@@ -5,7 +5,7 @@ import { ChatIdentity, chatRowClass } from '../ChatMessageRow/index.jsx';
export default function ChatTypingRow({ message }) {
return (
<div className={`${chatRowClass(message)} italic opacity-80 border-slate-600/40 bg-slate-900/40`}>
<div className={`${chatRowClass(message)} !flex-row items-center italic opacity-80 border-slate-600/40 bg-slate-900/40`}>
<ChatIdentity message={message} />
<span className="text-slate-300">typing...</span>
</div>
@@ -127,7 +127,7 @@ export default function DriveDockAction({
steps: [
'Line up the center dot of your rover with the center of the dock',
'The UI will indicate when you are successfully docked',
'When good contact is made, charging will start in about 5 seconds.',
'When good contact is made, the dock will stop flashing.',
],
};
@@ -8,11 +8,40 @@ const MOBILE_DISMISS_MS = 10000;
const MAX_FONT_PX = 28;
const MIN_FONT_PX = 14;
function hexToRgb(hex) {
const raw = String(hex || '').trim();
const normalized = raw.startsWith('#') ? raw.slice(1) : raw;
if (!/^[0-9a-fA-F]{3}$|^[0-9a-fA-F]{6}$/.test(normalized)) return null;
const expanded =
normalized.length === 3
? normalized
.split('')
.map((ch) => ch + ch)
.join('')
: normalized;
return {
r: Number.parseInt(expanded.slice(0, 2), 16),
g: Number.parseInt(expanded.slice(2, 4), 16),
b: Number.parseInt(expanded.slice(4, 6), 16),
};
}
function rgba(rgb, alpha) {
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${alpha})`;
}
export default function GlobalObjectiveBanner({ layout = 'desktop', className = '', dismissable = true }) {
const goalText = useSessionSelector((state) => {
const text = state.session?.globalObjective?.text;
return text ? String(text).trim() : '';
});
const ownRoverColor = useSessionSelector((state) => {
const roverId = String(state.session?.assignment?.roverId || '').trim();
if (!roverId) return null;
const roster = Array.isArray(state.session?.roster) ? state.session.roster : [];
const rover = roster.find((entry) => String(entry?.id) === roverId);
return rover?.color || null;
});
const isMobile = layout === 'mobile-portrait' || layout === 'mobile-landscape' || layout === 'mobile';
const [visible, setVisible] = useState(false);
const [fontSize, setFontSize] = useState(MAX_FONT_PX);
@@ -72,7 +101,7 @@ export default function GlobalObjectiveBanner({ layout = 'desktop', className =
const containerClass = useMemo(
() =>
[
'panel-section flex w-full items-center justify-center',
'panel-section flex w-full items-center justify-center border border-neutral-500/60 shadow-[0_1px_0_rgba(255,255,255,0.05)_inset,0_10px_24px_rgba(0,0,0,0.28)]',
isMobile ? 'rounded-none' : 'rounded',
'px-1 py-1 text-center font-semibold tracking-tight',
className,
@@ -81,6 +110,13 @@ export default function GlobalObjectiveBanner({ layout = 'desktop', className =
.join(' '),
[className, isMobile],
);
const accentRgb = hexToRgb(ownRoverColor);
const frameStyle = accentRgb
? {
borderColor: rgba(accentRgb, 0.35),
backgroundImage: `linear-gradient(90deg, rgba(23,23,23,0.96) 0%, rgba(38,38,38,0.94) 58%, ${rgba(accentRgb, 0.18)} 100%)`,
}
: undefined;
if (!goalText || !visible) return null;
@@ -94,7 +130,7 @@ export default function GlobalObjectiveBanner({ layout = 'desktop', className =
<div
ref={containerRef}
className={containerClass}
style={{ fontSize: `${fontSize}px`, lineHeight: 1.1 }}
style={{ ...(frameStyle || {}), fontSize: `${fontSize}px`, lineHeight: 1.1 }}
{...dismissProps}
>
<span className="flex w-full items-stretch gap-0.5 whitespace-nowrap rounded-md">
@@ -5,6 +5,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
import { useControlSystem } from '../../controls/index.js';
import { formatKeyLabel } from '../../controls/keymapUtils.js';
import CardFrame from '../CardFrame/index.jsx';
function StatusBadge({ label, tone = 'muted' }) {
const styles =
@@ -255,44 +256,41 @@ export default function HomeAssistantControls() {
if (!ha?.enabled) {
return (
<section className="panel-section space-y-0.5 text-sm text-slate-400">
<p className="text-slate-300">Room Controls</p>
<CardFrame title="Room Controls" bodyClassName="space-y-0.5 text-sm text-slate-400">
<p className="text-slate-500">Not configured on the server.</p>
</section>
</CardFrame>
);
}
if (entities.length === 0) {
return (
<section className="panel-section space-y-0.5 text-sm text-slate-400">
<p className="text-slate-300">Room Controls</p>
<CardFrame title="Room Controls" bodyClassName="space-y-0.5 text-sm text-slate-400">
<p className="text-slate-500">No lights or switches configured.</p>
</section>
</CardFrame>
);
}
const connected = Boolean(ha?.connected);
const actions = (
<>
<div className="flex items-center gap-0.5 text-[0.68rem] text-slate-400">
<span className="flex items-center gap-0.5">
<span>On</span>
{onKeyLabel ? <KeyPill label={onKeyLabel} /> : null}
</span>
<span className="flex items-center gap-0.5">
<span>Off</span>
{offKeyLabel ? <KeyPill label={offKeyLabel} /> : null}
</span>
</div>
{controlsLocked ? <StatusBadge label={lockState === 'off' ? 'Locked Off' : 'Locked On'} tone="warn" /> : null}
<StatusBadge label={connected ? 'Connected' : 'Offline'} tone={connected ? 'success' : 'warn'} />
</>
);
return (
<section className="panel-section space-y-0.5 text-base">
<header className="flex items-center justify-between gap-0.5 text-sm text-slate-400">
<div className="flex items-center gap-0.5">
<p>Room Controls</p>
<span className="text-xs text-slate-500">{entities.length}</span>
{controlsLocked ? <StatusBadge label={lockState === 'off' ? 'Locked Off' : 'Locked On'} tone="warn" /> : null}
<div className="flex items-center gap-0.5 text-xs text-slate-300 background-black">
<span className="flex items-center gap-0.5">
<span>On</span>
{onKeyLabel ? <KeyPill label={onKeyLabel} /> : null}
</span>
<span className="flex items-center gap-0.5">
<span>Off</span>
{offKeyLabel ? <KeyPill label={offKeyLabel} /> : null}
</span>
</div>
</div>
<StatusBadge label={connected ? 'Connected' : 'Offline'} tone={connected ? 'success' : 'warn'} />
</header>
<CardFrame title="Room Controls" actions={actions} bodyClassName="space-y-0.5 text-base">
{controlsLocked ? (
<p className="rounded border border-amber-600/60 bg-amber-900/40 px-1 py-0.5 text-xs text-amber-100">
{lockState === 'off'
@@ -313,11 +311,11 @@ export default function HomeAssistantControls() {
/>
))}
</div>
</section>
</CardFrame>
);
}
function KeyPill({ label }) {
if (!label) return null;
return <span className="rounded border border-white/40 px-1 text-[0.7rem] text-white">{label}</span>;
return <span className="rounded border border-slate-600 px-1 text-[0.65rem] text-slate-300">{label}</span>;
}
+37 -6
View File
@@ -1,15 +1,16 @@
// Horn Control
// Purpose: Defines the Horn Control module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useSettingsNamespace } from '../../settings/index.js';
import { HORN_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
import { HORN_MAX_FREQUENCY } from '../../controls/constants.js';
function clampFreq(value) {
const num = Number(value);
if (!Number.isFinite(num)) return 0;
if (num <= 0) return 0;
return Math.min(5000, Math.round(num));
return Math.min(HORN_MAX_FREQUENCY, Math.round(num));
}
export default function HornControl({
@@ -31,6 +32,7 @@ export default function HornControl({
HORN_SETTINGS_DEFAULTS,
);
const [waveform, setWaveform] = useState(hornSettings?.waveform || HORN_SETTINGS_DEFAULTS.waveform);
const settingsRootRef = useRef(null);
const [freqs, setFreqs] = useState(() => {
const base = Array.isArray(hornSettings?.freqs) ? hornSettings.freqs : HORN_SETTINGS_DEFAULTS.freqs;
return [...base, 0, 0, 0, 0].slice(0, 4).map((f) => clampFreq(f));
@@ -99,6 +101,30 @@ export default function HornControl({
[saveHornSettings],
);
useEffect(() => {
const root = settingsRootRef.current;
if (!root) return undefined;
const handleWheel = (event) => {
const target = event.target;
if (!(target instanceof Element)) return;
const input = target.closest('input[data-horn-freq-index]');
if (!input) return;
const index = Number(input.dataset.hornFreqIndex);
if (!Number.isInteger(index) || index < 0 || index >= freqs.length) return;
event.preventDefault();
event.stopPropagation();
const direction = event.deltaY < 0 ? 1 : -1;
const current = Number(freqs[index]) || 0;
updateFreq(index, current + direction * 25);
};
root.addEventListener('wheel', handleWheel, { passive: false, capture: true });
return () => {
root.removeEventListener('wheel', handleWheel, { capture: true });
};
}, [freqs, updateFreq]);
const handlePointerDown = (event) => {
if (disabled) return;
const tag = event.target?.tagName?.toLowerCase();
@@ -176,7 +202,7 @@ export default function HornControl({
</div>
{showSettings ? (
compactSettings ? (
<div className="relative z-10 flex flex-col gap-0.5 text-[0.65rem]">
<div ref={settingsRootRef} className="relative z-10 flex flex-col gap-0.5 text-[0.65rem]">
<label className="flex items-center justify-between gap-0.5 text-slate-200">
<span className="text-slate-300">Wave</span>
<select
@@ -195,9 +221,10 @@ export default function HornControl({
<input
type="number"
min={0}
max={5000}
max={HORN_MAX_FREQUENCY}
step={1}
value={freq}
data-horn-freq-index={idx}
onChange={(event) => updateFreq(idx, event.target.value)}
className="w-full rounded border border-slate-700 bg-slate-900 px-1 py-[2px] text-right text-[0.65rem] font-mono text-slate-100"
/>
@@ -206,7 +233,10 @@ export default function HornControl({
</div>
</div>
) : (
<div className="relative z-10 grid grid-cols-[minmax(0,1.2fr)_repeat(4,minmax(0,1fr))] items-center gap-0.5 text-[0.65rem]">
<div
ref={settingsRootRef}
className="relative z-10 grid grid-cols-[minmax(0,1.2fr)_repeat(4,minmax(0,1fr))] items-center gap-0.5 text-[0.65rem]"
>
<label className="flex items-center gap-0.5 text-slate-200">
<span className="text-slate-300">Wave</span>
<select
@@ -224,9 +254,10 @@ export default function HornControl({
<input
type="number"
min={0}
max={5000}
max={HORN_MAX_FREQUENCY}
step={1}
value={freq}
data-horn-freq-index={idx}
onChange={(event) => updateFreq(idx, event.target.value)}
className="w-full rounded border border-slate-700 bg-slate-900 px-1 py-[2px] text-right text-[0.65rem] font-mono text-slate-100"
/>
@@ -6,7 +6,6 @@ import SocialButton from '../../SocialButton/index.jsx';
function TurnsOverlay({
roverId = null,
mobileHud = false,
discordUrl: discordUrlProp = null,
}) {
const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
const effectiveRoverId = roverId ?? assignedRoverId;
@@ -16,19 +15,9 @@ function TurnsOverlay({
const turnQueues = useSessionSelector((state) => state.session?.turnQueues ?? {});
const socketId = useSessionSelector((state) => state.session?.socketId || null);
const activeDrivers = useSessionSelector((state) => state.session?.activeDrivers ?? {});
const discordUrl = useSessionSelector((state) => {
const socials = state.session?.socials || [];
const socialUrl =
socials.find((entry) => {
const key = String(entry?.id || entry?.label || '').toLowerCase();
return key === 'discord';
})?.url || null;
return socialUrl || state.session?.discord?.invite || null;
});
const {
state: { lastControlIntentAt },
} = useControlSystem();
const effectiveDiscordUrl = discordUrlProp || discordUrl;
const [now, setNow] = useState(() => Date.now());
const [showTurnCue, setShowTurnCue] = useState(false);
const [turnCueStartAt, setTurnCueStartAt] = useState(null);
@@ -214,11 +203,7 @@ function TurnsOverlay({
</div>
) : null}
<div className="pointer-events-auto mt-0.5">
<SocialButton
id="discord"
label="Join our Discord while you wait!"
url={effectiveDiscordUrl}
/>
<SocialButton id="discord" label="Join our Discord while you wait!" layout='inline'/>
</div>
</div>
</div>
+10 -13
View File
@@ -3,30 +3,27 @@
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { useSessionSelector } from '../../context/SessionContext.jsx';
import { useMemo } from 'react';
import CardFrame from '../CardFrame/index.jsx';
export default function LogPanel() {
const logs = useSessionSelector((state) => state.logs);
const rendered = useMemo(() => logs.slice().reverse(), [logs]);
return (
<div className="panel-section space-y-0.5 text-base">
<div className="flex items-center justify-between text-sm text-slate-400">
<span>Server logs</span>
<span>{logs.length}</span>
</div>
<CardFrame title="Server logs" bodyClassName="space-y-0.5 text-base">
<div className="surface h-64 overflow-y-auto font-mono text-xs">
{logs.length === 0 ? (
<p>No logs yet.</p>
) : (
rendered.map((entry) => (
<div key={entry.id} className="surface">
<span className="text-amber-400">{entry.timestamp}</span>{' '}
<span className="text-lime-400">[{entry.level}]</span>{' '}
{entry.label && <span className="text-teal-400">[{entry.label}]</span>}{' '}
<span>{entry.message}</span>
</div>
))
<div key={entry.id} className="surface">
<span className="text-amber-400">{entry.timestamp}</span>{' '}
<span className="text-lime-400">[{entry.level}]</span>{' '}
{entry.label && <span className="text-teal-400">[{entry.label}]</span>}{' '}
<span>{entry.message}</span>
</div>
))
)}
</div>
</div>
</CardFrame>
);
}
+2 -16
View File
@@ -6,7 +6,6 @@ import AuthPanel from '../AuthPanel/index.jsx';
import { useSessionSelector } from '../../context/SessionContext.jsx';
import SocialButton from '../SocialButton/index.jsx';
import ChatPanel from '../ChatPanel/index.jsx';
import NicknameForm from '../NicknameForm/index.jsx';
const PRIVILEGED_ROLES = new Set(['admin', 'lockdown', 'lockdown-admin']);
const LOCKDOWN_ROLES = new Set(['lockdown', 'lockdown-admin']);
@@ -33,14 +32,6 @@ export default function ModeGateOverlay() {
const reason = useSessionSelector((state) => state.session?.adminReason?.text || '');
const reasonUpdatedAt = useSessionSelector((state) => state.session?.adminReason?.updatedAt || null);
const timezone = useSessionSelector((state) => state.session?.timezone || 'UTC');
const discordUrl = useSessionSelector((state) => {
const socials = state.session?.socials || [];
const fromSocials = socials.find((entry) => {
const key = String(entry?.id || entry?.label || '').toLowerCase();
return key === 'discord';
})?.url;
return fromSocials || state.session?.discord?.invite || null;
});
const restricted = RESTRICTED_MODES.has(mode);
const privileged = mode === 'lockdown' ? LOCKDOWN_ROLES.has(role) : PRIVILEGED_ROLES.has(role);
const [now, setNow] = useState(() => new Date());
@@ -92,17 +83,12 @@ export default function ModeGateOverlay() {
<AuthPanel />
</div>
<div className="w-full justify-center items-center">
<SocialButton
id="discord"
label="Join our Discord server for updates!"
url={discordUrl}
/>
<SocialButton id="discord" label="Join our Discord server for updates!"/>
</div>
You can still use the chat while the server is locked:
{/* set max height of this box */}
<div className='max-h-80 overflow-y-auto'>
<ChatPanel />
<NicknameForm />
<ChatPanel nicknameLayout="stacked" />
</div>
{/* <p className="text-xs text-slate-500">
@@ -0,0 +1,50 @@
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
import { useSettingsNamespace } from '../../settings/index.js';
import CardFrame from '../CardFrame/index.jsx';
export default function OverseerPreferencePanel() {
const { identifySession } = useSessionActions();
const vote = useSessionSelector((state) => state.session?.overseerVote || null);
const { value: profile } = useSettingsNamespace('profile', { nickname: '' });
const { value: identity } = useSettingsNamespace('identity', { cookieUserId: '' });
const { value, save } = useSettingsNamespace('overseerPreference', { enabled: false });
const enabled = Boolean(value?.enabled);
const running = Boolean(vote?.running);
const onToggle = async (event) => {
const next = Boolean(event?.target?.checked);
await save((current) => ({ ...(current || {}), enabled: next }));
await identifySession({
cookieUserId: String(identity?.cookieUserId || '').trim(),
nickname: String(profile?.nickname || '').trim(),
overseerEnabled: next,
});
};
return (
<CardFrame title="Overseer vote" bodyClassName="space-y-0.5 text-center text-xs text-slate-200">
<label
className={`flex items-center justify-center gap-1.5 rounded px-1 py-0.5 ${
enabled ? 'bg-emerald-600/80 text-emerald-50' : 'bg-slate-600/70 text-slate-100'
}`}
>
<input type="checkbox" checked={enabled} onChange={onToggle} />
<span>Your vote: {enabled ? 'Yes' : 'No'}</span>
</label>
<span className="text-xs">Enable a local LLM in chat</span>
<div className="mt-0.5 text-slate-400">
Yes!: {Number(vote?.yesCount || 0)}, No...: {Number(vote?.noCount || 0)}
</div>
<div className="mt-0.5 flex justify-center">
<span
className={`inline-flex items-center rounded px-1.5 py-0.5 text-[0.7rem] font-medium ${
running ? 'bg-emerald-600/80 text-emerald-50' : 'bg-slate-600/70 text-slate-100'
}`}
>
{running ? 'Running!' : 'Stopped by vote.'}
</span>
</div>
</CardFrame>
);
}
@@ -4,7 +4,6 @@ import { formatKeyLabel } from '../../controls/keymapUtils.js';
import NicknameForm from '../NicknameForm/index.jsx';
import SocialButton from '../SocialButton/index.jsx';
import KeyPill from '../vip/VipAudioUploadCard/KeyPill.jsx';
import { useSessionSelector } from '../../context/SessionContext.jsx';
function ControlRow({ label, keyLabel }) {
return (
@@ -56,15 +55,6 @@ export default function QuickstartOverlay({
}) {
const { state } = useControlSystem();
const isDesktop = layout === 'desktop';
const discordUrl = useSessionSelector((sessionState) => {
const socials = sessionState.session?.socials || [];
const entry = socials.find((item) => {
const key = String(item?.id || item?.label || '').toLowerCase();
return key === 'discord';
});
return entry?.url || sessionState.session?.discord?.invite || null;
});
const keymap = useMemo(() => state?.keymap || {}, [state?.keymap]);
if (!visible) return null;
@@ -98,7 +88,7 @@ export default function QuickstartOverlay({
<div className="surface p-0.5">
<p className="text-xl font-semibold text-slate-200">Join our Discord server!</p>
<p className="text-sm font-semibold text-slate-200">We have an active and welcoming community :3</p>
<SocialButton id="discord" label="Join Discord" url={discordUrl} />
<SocialButton id="discord" label="Join Discord" />
</div>
</section>
</div>
@@ -5,6 +5,7 @@ import { useMemo } from 'react';
import { useSessionSelector } from '../../context/SessionContext.jsx';
import NicknameForm from '../NicknameForm/index.jsx';
import SocialButtonsGrid from '../SocialButtonsGrid/index.jsx';
import CardFrame from '../CardFrame/index.jsx';
function roleColors(role) {
switch (role) {
@@ -57,8 +58,13 @@ export default function RawUserPilePanel({
: 'h-48 overflow-y-auto';
return (
<section
className={`panel-section space-y-0.5 text-base ${fillHeight ? 'flex h-full min-h-0 flex-col overflow-hidden' : ''} ${className}`}
<CardFrame
title={!hideHeader ? 'Users' : ''}
hideHeader={hideHeader}
fillHeight={fillHeight}
className={className}
bodyClassName="space-y-0.5 text-base"
>
{!hideNicknameForm && (
<div className="space-y-0.5">
@@ -75,12 +81,6 @@ export default function RawUserPilePanel({
)}
<div className={`space-y-0.5 ${fillHeight ? 'flex flex-1 min-h-0 flex-col' : ''}`}>
{!hideHeader && (
<div className={`flex items-center justify-between text-sm text-slate-400 ${compact ? 'text-xs' : ''}`}>
<span>Users</span>
<span className="text-xs text-slate-500">{sorted.length}</span>
</div>
)}
<div
className={`surface flex flex-wrap content-start items-start gap-0.5 px-0 pb-0 ${baseListClass} ${compact ? 'text-[0.8rem]' : ''}`}
>
@@ -98,6 +98,6 @@ export default function RawUserPilePanel({
)}
</div>
</div>
</section>
</CardFrame>
);
}
@@ -5,6 +5,7 @@ import { useEffect, useMemo, useState } from 'react';
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
import { useSettingsNamespace } from '../../settings/index.js';
import { roverNameChromeStyle } from '../../lib/roverColor.js';
import CardFrame from '../CardFrame/index.jsx';
function normalizeSources(list = []) {
return list
@@ -63,13 +64,8 @@ export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHei
}, [users, selfSocketId, assignmentRoverId, roster]);
useEffect(() => {
const saved = settings?.[panelId];
if (Array.isArray(saved) && saved.length) {
setSelected(saved);
return;
}
setSelected(defaults);
}, [settings?.[panelId], defaults, panelId]);
}, [defaults]);
useEffect(() => {
const saved = settings?.[`${panelId}:includeSidebar`];
@@ -80,6 +76,16 @@ export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHei
setIncludeSidebar(true);
}, [settings?.[`${panelId}:includeSidebar`], panelId]);
useEffect(() => {
const saved = settings?.[`${panelId}:title`];
if (typeof saved === 'string') {
setTitle(saved);
setTitleDirty(true);
return;
}
setTitleDirty(false);
}, [settings?.[`${panelId}:title`], panelId]);
useEffect(() => {
if (!titleDirty) {
setTitle(defaultTitle);
@@ -116,9 +122,7 @@ export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHei
const toggleKey = (key) => {
setSelected((prev) => {
const next = prev.includes(key) ? prev.filter((value) => value !== key) : [...prev, key];
saveSettings((current) => ({ ...(current || {}), [panelId]: next }));
return next;
return prev.includes(key) ? prev.filter((value) => value !== key) : [...prev, key];
});
setSuccess(null);
};
@@ -143,15 +147,10 @@ export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHei
}
};
const containerClass = fillHeight ? 'h-full flex flex-col' : '';
const listWrapClass = fillHeight ? 'flex-1 min-h-0 overflow-y-auto' : '';
return (
<section className={`panel-section p-0.75 text-sm ${containerClass}`}>
<header className="panel-muted flex items-center justify-between text-xs">
<span>Replay Sources</span>
<span>{sources.length}</span>
</header>
<CardFrame title="Replay Sources" fillHeight={fillHeight} bodyClassName="space-y-0.5 text-sm">
<div className={`grid gap-0.5 md:grid-cols-2 ${listWrapClass}`}>
<GroupList title="Rovers" items={grouped.rovers} selected={selected} onToggle={toggleKey} />
<GroupList title="Room Cams" items={grouped.rooms} selected={selected} onToggle={toggleKey} />
@@ -166,8 +165,10 @@ export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHei
className="field-input w-full text-xs"
value={title}
onChange={(event) => {
setTitle(event.target.value);
const next = event.target.value;
setTitle(next);
setTitleDirty(true);
saveSettings((current) => ({ ...(current || {}), [`${panelId}:title`]: next }));
}}
placeholder={defaultTitle}
maxLength={120}
@@ -196,7 +197,7 @@ export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHei
{error ? <div className="text-xs text-amber-400">{error}</div> : null}
{success ? <div className="text-xs text-emerald-300">{success}</div> : null}
</div>
</section>
</CardFrame>
);
}
+8 -5
View File
@@ -6,7 +6,7 @@ import HomeAssistantControls from '../HomeAssistantControls/index.jsx';
import SettingsPanel from '../SettingsPanel/index.jsx';
import HelpPanel from '../HelpPanel/index.jsx';
import ChatPanel from '../ChatPanel/index.jsx';
import { LinkButtonsPanel, NicknameEntryPanel } from '../UserListPanel/index.jsx';
import { LinkButtonsPanel } from '../UserListPanel/index.jsx';
import ReplaySourcesPanel from '../ReplaySourcesPanel/index.jsx';
import Tabs, { Tab, TabList, TabPanel, TabPanels } from '../Tabs/index.jsx';
import TopDownMap from '../TopDownMap/index.jsx';
@@ -23,6 +23,8 @@ import VipPanel from '../VipPanel/index.jsx';
import { useSessionSelector } from '../../context/SessionContext.jsx';
import { useSettingsNamespace } from '../../settings/index.js';
import ButtonBoxPanel from '../ButtonBoxPanel/index.jsx';
import OverseerPreferencePanel from '../OverseerPreferencePanel/index.jsx';
import CardFrame from '../CardFrame/index.jsx';
import { useState } from 'react';
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
@@ -34,11 +36,11 @@ function TopDownMapPanel() {
const sensors = frame?.sensors || {};
return (
<section className="panel-section">
<CardFrame hideHeader>
<div className="aspect-square w-full">
<TopDownMap sensors={sensors} />
</div>
</section>
</CardFrame>
);
}
@@ -100,6 +102,7 @@ function DriveDockPanel() {
value={value}
min={min}
max={max}
label="Camera tilt"
disabled={cameraDisabled}
onChange={setServoAngle}
keyDownLabel={downLabel}
@@ -177,9 +180,9 @@ export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
</div>
<div className="grid items-stretch gap-0.5 grid-cols-[minmax(0,1.3fr)_minmax(0,0.22fr)] h-[14rem]">
<ChatPanel fillHeight />
<div className="grid min-h-0 gap-0.5 grid-rows-[minmax(0,1fr)_auto]">
<div className="grid min-h-0 gap-0.5 grid-rows-[auto_minmax(0,1fr)]">
<OverseerPreferencePanel />
<RawUserPilePanel compact hideNicknameForm fillHeight />
<NicknameEntryPanel compact />
</div>
</div>
<HomeAssistantControls />
+27 -29
View File
@@ -6,6 +6,7 @@ import { useSessionSelector } from '../../context/SessionContext.jsx';
import { useSettingsNamespace } from '../../settings/index.js';
import { useRoomCameraSnapshots } from '../../hooks/useRoomCameraSnapshots.js';
import RoomCameraFeed from '../RoomCameraFeed/index.jsx';
import CardFrame from '../CardFrame/index.jsx';
function EmptyState() {
return (
@@ -66,35 +67,32 @@ export default function RoomCameraPanel({
return <EmptyState />;
}
const actions = showLayoutToggle ? (
<div className="flex items-center gap-0.5 text-[0.68rem] text-slate-400">
<span>Layout</span>
<div className="inline-flex overflow-hidden rounded border border-slate-700">
{ORIENTATIONS.map((option) => (
<button
key={option}
type="button"
className={`px-1 py-0.5 ${effectiveOrientation === option ? 'bg-slate-600 text-white' : 'bg-transparent text-slate-400 hover:text-white'}`}
onClick={() => applyOrientation(option)}
>
{option === 'vertical' ? 'Vertical' : 'Grid'}
</button>
))}
</div>
</div>
) : null;
return (
<section className="panel-section space-y-0.5 text-base">
{!hideHeader && (
<header className="flex flex-wrap items-center justify-between gap-0.5 text-sm text-slate-400">
<div className="flex items-center gap-0.5">
<p>Room cameras</p>
<span className="text-xs text-slate-500">{cameras.length}</span>
</div>
<div className="flex flex-wrap items-center gap-0.5 text-xs">
{showLayoutToggle && (
<div className="flex items-center gap-0.5">
<span className="text-slate-500">Layout:</span>
<div className="inline-flex overflow-hidden rounded border border-slate-700">
{ORIENTATIONS.map((option) => (
<button
key={option}
type="button"
className={`px-1 py-0.5 ${effectiveOrientation === option ? 'bg-slate-600 text-white' : 'bg-transparent text-slate-400 hover:text-white'}`}
onClick={() => applyOrientation(option)}
>
{option === 'vertical' ? 'Vertical' : 'Grid'}
</button>
))}
</div>
</div>
)}
</div>
</header>
)}
<CardFrame
title="Room cameras"
actions={actions}
hideHeader={hideHeader}
bodyClassName="space-y-0.5 text-base"
>
<div className={containerClass}>
{cameras.map((camera) => {
const feed = feedMap[camera.id] || null;
@@ -109,6 +107,6 @@ export default function RoomCameraPanel({
);
})}
</div>
</section>
</CardFrame>
);
}
@@ -4,6 +4,7 @@
import { useEffect, useMemo, useState } from 'react';
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
import { roverNameChromeStyle } from '../../lib/roverColor.js';
import CardFrame from '../CardFrame/index.jsx';
function classNames(...values) {
return values.filter(Boolean).join(' ');
@@ -96,8 +97,7 @@ export default function RoverQueuesPanel({ title = 'Rovers' }) {
users.find((u) => u.socketId === socketId) || { socketId, nickname: null, role: null };
return (
<div className="space-y-0.5 text-sm">
{title && <p className="text-sm text-slate-400">{title}</p>}
<CardFrame title={title} bodyClassName="space-y-0.5 text-sm">
{rosterItems.length === 0 ? (
<p className="text-sm text-slate-500">No rovers registered.</p>
) : (
@@ -216,6 +216,6 @@ export default function RoverQueuesPanel({ title = 'Rovers' }) {
})}
</ul>
)}
</div>
</CardFrame>
);
}
+41 -79
View File
@@ -1,97 +1,59 @@
// Social Button
// Purpose: Defines the Social Button module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { useEffect } from 'react';
import { FaBook, FaCoffee, FaCrown, FaDiscord, FaLink } from 'react-icons/fa';
import * as FaIcons from 'react-icons/fa';
import { FaLink } from 'react-icons/fa';
import { useSessionSelector } from '../../context/SessionContext.jsx';
import { getSocialById } from '../../lib/socials.js';
const ICONS_BY_ID = {
discord: FaDiscord,
kofi: FaCoffee,
'ko-fi': FaCoffee,
wiki: FaBook,
throne: FaCrown,
};
const GRADIENT_STYLE_BY_ID = {
discord: {
backgroundImage:
'linear-gradient(270deg,#5865F2,#404EED,#5865F2)',
backgroundSize: '600% 600%',
animation: 'socialGradient 180s ease infinite',
},
kofi: {
backgroundImage: 'linear-gradient(270deg,#FF6433,#E04822,#FF6433)',
backgroundSize: '600% 600%',
animation: 'socialGradient 170s ease infinite',
},
'ko-fi': {
backgroundImage: 'linear-gradient(270deg,#FF6433,#E04822,#FF6433)',
backgroundSize: '600% 600%',
animation: 'socialGradient 170s ease infinite',
},
wiki: {
backgroundImage: 'linear-gradient(270deg,#EE8019,#C65C08,#EE8019)',
backgroundSize: '600% 600%',
animation: 'socialGradient 180s ease infinite',
},
throne: {
backgroundImage: 'linear-gradient(270deg,#7C3AED,#5B21B6,#7C3AED)',
backgroundSize: '600% 600%',
animation: 'socialGradient 180s ease infinite',
},
};
function useSocialButtonStyles() {
useEffect(() => {
if (typeof document === 'undefined') return;
const styleId = 'social-button-keyframes';
if (document.getElementById(styleId)) return;
const style = document.createElement('style');
style.id = styleId;
style.textContent = `
@keyframes socialGradient {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
`;
document.head.appendChild(style);
}, []);
function sanitizeCssColor(value) {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
if (!trimmed) return null;
if (/^#[0-9a-fA-F]{3,8}$/.test(trimmed)) return trimmed;
if (/^(rgb|rgba|hsl|hsla)\([^)]+\)$/.test(trimmed)) return trimmed;
return null;
}
function normalizeId(id, label) {
if (typeof id === 'string' && id.trim()) return id.trim().toLowerCase();
if (typeof label === 'string' && label.trim()) {
return label
.trim()
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-]/g, '');
}
return '';
function resolveIcon(iconName) {
if (typeof iconName !== 'string' || !iconName.trim()) return FaLink;
const icon = FaIcons[iconName.trim()];
return typeof icon === 'function' ? icon : FaLink;
}
export default function SocialButton({ id, label, url, className = '' }) {
if (!url) return null;
useSocialButtonStyles();
const key = normalizeId(id, label);
const Icon = ICONS_BY_ID[key] || FaLink;
const gradientStyle = GRADIENT_STYLE_BY_ID[key] || null;
const text = label || id || 'Link';
export default function SocialButton({ id = null, label, url, icon, color, layout = 'stacked', className = '' }) {
const socialFromId = useSessionSelector((state) => (id ? getSocialById(state, id) : null));
const resolvedUrl = url || socialFromId?.url || null;
if (!resolvedUrl) return null;
const resolvedIcon = icon || socialFromId?.icon || null;
const resolvedColor = color || socialFromId?.color || null;
const Icon = resolveIcon(resolvedIcon);
const bgColor = sanitizeCssColor(resolvedColor);
const text = label || socialFromId?.label || 'Link';
const isInline = layout === 'inline';
return (
<a
href={url}
href={resolvedUrl}
target="_blank"
rel="noopener noreferrer"
aria-label={text}
className={`inline-flex items-center w-full px-0.5 py-0.5 text-sm font-medium text-white transition justify-center gap-1 rounded-md ${
gradientStyle ? '' : 'bg-slate-700 hover:bg-slate-600'
} ${className}`}
style={gradientStyle || undefined}
className={`grid h-full min-h-0 w-full place-items-center rounded-md bg-slate-700 px-0.5 ${isInline ? 'py-0.5' : 'pb-3'} text-center text-sm font-medium text-white transition hover:opacity-90 ${className}`}
style={bgColor ? { backgroundColor: bgColor } : undefined}
>
<Icon className="mr-0" />
{text}
{isInline ? (
<span className="inline-flex max-w-full items-center justify-center gap-1 text-center leading-tight">
<Icon className="shrink-0" style={{ fontSize: '1.1em' }} />
<span className="break-words">{text}</span>
</span>
) : (
<span className="flex h-full w-full max-w-full flex-col items-center justify-between text-center leading-tight">
<span className="flex min-h-0 flex-1 items-center justify-center">
<Icon className="shrink-0" style={{ fontSize: 'clamp(1rem, 2.2vh + 1.2vw, 3rem)' }} />
</span>
<span className="w-full break-words leading-tight">{text}</span>
</span>
)}
</a>
);
}
@@ -4,46 +4,18 @@
import { useSessionSelector } from '../../context/SessionContext.jsx';
import SocialButton from '../SocialButton/index.jsx';
function normalizeSocials({ socials, discordInvite, kofiLink }) {
const configured = Array.isArray(socials) ? socials : null;
if (configured && configured.length) {
return configured;
}
const fallback = [];
if (discordInvite) {
fallback.push({ id: 'discord', label: 'Discord', url: discordInvite });
}
if (kofiLink) {
fallback.push({ id: 'kofi', label: 'Ko-fi', url: kofiLink });
}
return fallback;
}
function normalizeEntry(entry) {
if (!entry || typeof entry !== 'object') return null;
const id = entry.id || entry.service || entry.key || entry.name || null;
const label = entry.label || entry.title || entry.name || id || 'Link';
const url = entry.url || entry.link || entry.href || null;
return url ? { id, label, url } : null;
}
export default function SocialButtonsGrid({ className = '' }) {
const socialsInput = useSessionSelector((state) => ({
socials: state.session?.socials ?? [],
discordInvite: state.session?.discord?.invite || null,
kofiLink: state.session?.kofi?.link || null,
}));
const socials = normalizeSocials(socialsInput)
.map(normalizeEntry)
.filter(Boolean)
.slice(0, 4);
const socials = useSessionSelector((state) => state.session?.socials ?? []).slice(0, 4);
if (!socials.length) return null;
return (
<div className={`grid grid-cols-2 grid-rows-2 gap-0.5 ${className}`}>
{socials.map((entry) => (
<SocialButton key={`${entry.id || entry.label}-${entry.url}`} {...entry} />
<SocialButton
key={`${entry?.id || entry?.label || 'social'}-${entry?.url || ''}`}
id={entry?.id || entry?.key || entry?.service || null}
/>
))}
</div>
);
+49 -1
View File
@@ -2,6 +2,7 @@
// Purpose: Defines the Tabs module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { createContext, useCallback, useContext, useMemo, useState, useEffect } from 'react';
import { useSessionSelector } from '../../context/SessionContext.jsx';
const TabsContext = createContext(null);
@@ -33,6 +34,28 @@ function classNames(...parts) {
return parts.filter(Boolean).join(' ');
}
function hexToRgb(hex) {
const raw = String(hex || '').trim();
const normalized = raw.startsWith('#') ? raw.slice(1) : raw;
if (!/^[0-9a-fA-F]{3}$|^[0-9a-fA-F]{6}$/.test(normalized)) return null;
const expanded =
normalized.length === 3
? normalized
.split('')
.map((ch) => ch + ch)
.join('')
: normalized;
return {
r: Number.parseInt(expanded.slice(0, 2), 16),
g: Number.parseInt(expanded.slice(2, 4), 16),
b: Number.parseInt(expanded.slice(4, 6), 16),
};
}
function rgba(rgb, alpha) {
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${alpha})`;
}
export default function Tabs({ children, defaultTab, currentTab, onTabChange, variant = DEFAULT_VARIANT }) {
const [internalTab, setInternalTab] = useState(defaultTab ?? null);
const [tabOrder, setTabOrder] = useState([]);
@@ -95,7 +118,32 @@ export default function Tabs({ children, defaultTab, currentTab, onTabChange, va
}
export function TabList({ children, className = '' }) {
return <div className={classNames('flex gap-0.5', className)}>{children}</div>;
const ownRoverColor = useSessionSelector((state) => {
const roverId = String(state.session?.assignment?.roverId || '').trim();
if (!roverId) return null;
const roster = Array.isArray(state.session?.roster) ? state.session.roster : [];
const rover = roster.find((entry) => String(entry?.id) === roverId);
return rover?.color || null;
});
const accentRgb = hexToRgb(ownRoverColor);
const frameStyle = accentRgb ? { borderColor: rgba(accentRgb, 0.35) } : undefined;
const headerStyle = accentRgb
? {
backgroundImage: `linear-gradient(90deg, rgba(23,23,23,0.96) 0%, rgba(38,38,38,0.94) 58%, ${rgba(accentRgb, 0.18)} 100%)`,
}
: undefined;
return (
<div
className={classNames(
'panel-section overflow-hidden border border-neutral-500/60 bg-neutral-900/95 p-0.5 shadow-[0_1px_0_rgba(255,255,255,0.05)_inset,0_10px_24px_rgba(0,0,0,0.28)]',
className
)}
style={frameStyle}
>
<div className="flex gap-0.5" style={headerStyle}>{children}</div>
</div>
);
}
export function Tab({ id, children, className = '', disabled = false, highlight = 'none' }) {
@@ -5,6 +5,7 @@ import { useMemo } from 'react';
import { useSessionSelector } from '../../context/SessionContext.jsx';
import { useTelemetryFrame } from '../../context/TelemetryContext.jsx';
import { useDockIr } from '../../hooks/useDockIr.js';
import CardFrame from '../CardFrame/index.jsx';
function formatMetric(value, fallback = '--') {
if (value == null || value === '') return fallback;
@@ -39,7 +40,7 @@ export default function TelemetryPanel() {
}, [activeDriverId, roverId, selfSocketId, users]);
return (
<section className="panel-section space-y-0.5 text-base text-slate-100">
<CardFrame hideHeader clipOverflow={false} bodyClassName="space-y-0.5 text-base text-slate-100">
{/* <div className="text-sm text-slate-400">
<span>{connected ? 'online' : 'offline'}</span>
<span> · role {session?.role || 'unknown'}</span>
@@ -60,7 +61,7 @@ export default function TelemetryPanel() {
{rawSnippet && (
<pre className="surface whitespace-pre-wrap break-words text-xs text-lime-300">{rawSnippet}</pre>
)}
</section>
</CardFrame>
);
}
@@ -173,7 +174,7 @@ function SensorDetails({ sensors, dockState }) {
function DetailCard({ title, children }) {
return (
<div className="surface space-y-0.5 p-1 text-sm">
<div className="text-[0.8rem] uppercase tracking-wide text-slate-400">{title}</div>
<div className="text-[0.78rem] font-semibold leading-none text-slate-200">{title}</div>
<div className="space-y-0.5">{children}</div>
</div>
);
@@ -68,12 +68,6 @@ function TopDownMapContent({ sensors = {}, variant = 'full', size: overrideSize,
className={`${overlay ? 'relative' : 'surface relative p-1'}`}
style={overlay ? { width: `${size}px`, height: `${size}px` } : { height: '100%', width: '100%', aspectRatio: '1 / 1' }}
>
{!overlay ? (
<>
<div className="absolute left-1 top-1 text-xs text-slate-400">Top-down</div>
<div className="absolute right-1 top-1 text-[0.65rem] text-slate-500">0{maxLight}</div>
</>
) : null}
<svg width="100%" height="100%" viewBox={`0 0 ${size} ${size}`} preserveAspectRatio="xMidYMid meet" className="mx-auto block">
<circle cx={centerX} cy={centerY} r={innerCircle} fill="#0f172a" stroke="#334155" strokeWidth="2" />
<WheelVisual cx={centerX - wheelLineOffset} cy={centerY} current={wheelCurrentLeft} drop={bumps.wheelDropLeft} overcurrent={wheelOver.leftWheel} label="L" />
+3 -2
View File
@@ -6,6 +6,7 @@ import { useSessionSelector } from '../../context/SessionContext.jsx';
import NicknameForm from '../NicknameForm/index.jsx';
import SocialButtonsGrid from '../SocialButtonsGrid/index.jsx';
import { roverBadgeStyle, roverNameChromeStyle } from '../../lib/roverColor.js';
import CardFrame from '../CardFrame/index.jsx';
export function NicknameEntryPanel({ compact = false }) {
return (
@@ -19,9 +20,9 @@ export function NicknameEntryPanel({ compact = false }) {
export function LinkButtonsPanel() {
return (
<section className="panel-section flex h-full min-h-0 flex-col gap-0.5 text-base">
<CardFrame title="Links!" fillHeight bodyClassName="flex flex-1 min-h-0 flex-col gap-0.5 text-base">
<SocialButtonsGrid className="flex-1 min-h-0" />
</section>
</CardFrame>
);
}
@@ -17,6 +17,7 @@ import {
import { mergeFloatChunks, encodeWavMono16 } from './audioCodec.js';
import StatusIndicator from './StatusIndicator.jsx';
import KeyPill from './KeyPill.jsx';
import CardFrame from '../../CardFrame/index.jsx';
export default function VipAudioUploadCard({
ownRoverId = '',
@@ -529,9 +530,8 @@ export default function VipAudioUploadCard({
);
return (
<section className="surface">
<CardFrame title="Audio Controls">
<div className="grid gap-1">
<p className="text-sm text-slate-100 text-center">Audio Controls</p>
<section className="surface">
<div className="flex items-center justify-center gap-0.5 py-0.25 text-xs text-slate-300">
<span>Push-to-Talk Key</span>
@@ -653,6 +653,6 @@ export default function VipAudioUploadCard({
</div>
</section>
</div>
</section>
</CardFrame>
);
}
+3 -3
View File
@@ -2,6 +2,7 @@
// Purpose: Defines the Vip Identity Card module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { useState } from 'react';
import CardFrame from '../CardFrame/index.jsx';
import { fieldClass, flowWrapClass, innerFlowClass, maskKey } from './constants.js';
export default function VipIdentityCard({ currentStoredKey, applyIdentityKey, onMessage, fullWidth = false }) {
@@ -40,9 +41,8 @@ export default function VipIdentityCard({ currentStoredKey, applyIdentityKey, on
const wrapClass = fullWidth ? 'w-full' : flowWrapClass;
return (
<section className={`surface ${wrapClass}`}>
<CardFrame title="Identity key" className={wrapClass}>
<div className={innerFlowClass}>
<p className="text-sm text-slate-300">Identity key</p>
<p className="text-xs text-slate-500">Current: {maskKey(currentStoredKey) || 'not set yet'}</p>
<input
className={fieldClass}
@@ -109,6 +109,6 @@ export default function VipIdentityCard({ currentStoredKey, applyIdentityKey, on
</form>
) : null}
</div>
</section>
</CardFrame>
);
}
+16 -13
View File
@@ -2,6 +2,7 @@
// Purpose: Renders a shared lift controller panel synced from server session state.
// Scope: Presents verified-user controls while reflecting global busy/position/cooldown state.
import { useEffect, useMemo, useState } from 'react';
import CardFrame from '../CardFrame/index.jsx';
function badgeClass(tone) {
if (tone === 'good') return 'bg-emerald-600 text-white';
@@ -67,7 +68,17 @@ export default function VipLiftCard({ lift, onUp, onDown, fullWidth = false }) {
};
return (
<section className={`surface relative text-sm text-slate-200 ${wrapClass}`}>
<CardFrame
title="Lift Controls"
className={`relative ${wrapClass}`}
bodyClassName="text-sm text-slate-200"
actions={
<span className={`inline-flex w-auto rounded px-1 py-0.25 text-xs font-semibold ${badgeClass(statusTone)}`}>
{status}
</span>
}
>
{blocked ? (
<div className="absolute inset-0 z-20 flex items-center justify-center rounded-md bg-slate-950/80 px-1.5 text-center">
<div className="space-y-0.25">
@@ -82,18 +93,10 @@ export default function VipLiftCard({ lift, onUp, onDown, fullWidth = false }) {
) : null}
</div>
</div>
) : null}
) : null}
<div className="grid gap-0.5">
<div className="relative flex items-center justify-center min-h-[1.5rem]">
<div className="text-center">
<p className="text-sm text-slate-100">Lift Controls</p>
<p className="text-xs text-slate-400">Move the lift up and down. Please don't break anything...</p>
</div>
<span
className={`absolute right-0 inline-flex w-auto rounded px-1 py-0.25 text-xs font-semibold ${badgeClass(statusTone)}`}
>
{status}
</span>
<div className="text-center">
<p className="text-xs text-slate-400">Move the lift up and down. Please don't break anything...</p>
</div>
<section className="surface-muted px-0.5 py-0.5">
@@ -133,6 +136,6 @@ export default function VipLiftCard({ lift, onUp, onDown, fullWidth = false }) {
{lift?.lastError ? <p className="text-xs text-rose-300 text-center">Last error: {lift.lastError}</p> : null}
</div>
</section>
</CardFrame>
);
}
+15 -12
View File
@@ -2,6 +2,7 @@
// Purpose: Defines the Vip Neato Card module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { useState } from 'react';
import CardFrame from '../CardFrame/index.jsx';
function normalizeState(value) {
return String(value || '').trim();
@@ -98,18 +99,20 @@ export default function VipNeatoCard({
const primaryState = docked ? 'Docked' : uiStateLabel !== '--' ? uiStateLabel : 'Away from dock';
return (
<section className={`surface text-sm text-slate-200 ${wrapClass}`}>
<CardFrame
title="Neato Controls"
className={wrapClass}
bodyClassName="text-sm text-slate-200"
actions={
<span className={`inline-flex w-auto rounded px-1 py-0.25 text-xs font-semibold ${metricToneClass(headerTone)}`}>
{headerStatus}
</span>
}
>
<div className="grid gap-0.5">
<div className="relative flex items-center justify-center min-h-[1.5rem]">
<div className="text-center">
<p className="text-sm text-slate-100">Neato Controls</p>
<p className="text-xs text-slate-400">Control the autonomous Neato robovac. Be nice to him.</p>
</div>
<span
className={`absolute right-0 inline-flex w-auto rounded px-1 py-0.25 text-xs font-semibold ${metricToneClass(headerTone)}`}
>
{headerStatus}
</span>
<div className="text-center">
<p className="text-xs text-slate-400">Control the autonomous Neato robovac. Be nice to him.</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-0.5">
@@ -217,6 +220,6 @@ export default function VipNeatoCard({
</p>
) : null}
</div>
</section>
</CardFrame>
);
}
@@ -2,6 +2,7 @@
// Purpose: Defines the Vip Private Rover Access Card module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { useMemo, useState } from 'react';
import CardFrame from '../CardFrame/index.jsx';
import { flowWrapClass, innerFlowClass } from './constants.js';
export default function VipPrivateRoverAccessCard({
@@ -42,9 +43,8 @@ export default function VipPrivateRoverAccessCard({
};
return (
<section className={`surface text-sm text-slate-300 ${wrapClass}`}>
<CardFrame title="Private rover access requests" className={wrapClass} bodyClassName="text-sm text-slate-300">
<div className={innerFlowClass}>
<p className="text-sm text-slate-300">Private rover access requests</p>
{requestableRovers.length === 0 ? (
<p className="text-xs text-slate-500">No closed private rovers are available to request right now.</p>
) : (
@@ -79,7 +79,6 @@ export default function VipPrivateRoverAccessCard({
</div>
)}
</div>
</section>
</CardFrame>
);
}
@@ -3,6 +3,7 @@
// Scope: Owns local URL validation UX and writes to the existing profile settings namespace.
import { useEffect, useMemo, useState } from 'react';
import { useSettingsNamespace } from '../../settings/index.js';
import CardFrame from '../CardFrame/index.jsx';
import { fieldClass, flowWrapClass, innerFlowClass } from './constants.js';
function normalizeHttpUrl(value) {
@@ -54,9 +55,8 @@ export default function VipProfileImageCard({ isVerified = false, fullWidth = fa
};
return (
<section className={`surface text-sm text-slate-300 ${wrapClass}`}>
<CardFrame title="Chat profile image URL" className={wrapClass} bodyClassName="text-sm text-slate-300">
<form className={innerFlowClass} onSubmit={handleSave}>
<p className="text-sm text-slate-300">Chat profile image URL</p>
<p className="text-xs text-slate-500">
Verified users can set a custom avatar for chat and Discord bridge messages.
</p>
@@ -86,6 +86,6 @@ export default function VipProfileImageCard({ isVerified = false, fullWidth = fa
</button>
</div>
</form>
</section>
</CardFrame>
);
}
@@ -3,6 +3,7 @@
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { useState } from 'react';
import NicknameForm from '../NicknameForm/index.jsx';
import CardFrame from '../CardFrame/index.jsx';
import { flowWrapClass, innerFlowClass, fieldClass } from './constants.js';
export default function VipVerificationCard({
@@ -61,29 +62,28 @@ export default function VipVerificationCard({
if (pendingRequestId) {
return (
<section className={`surface text-sm text-slate-300 ${wrapClass}`}>
<CardFrame title="Verification" className={wrapClass} bodyClassName="text-sm text-slate-300">
<div className={innerFlowClass}>Verification request pending: {pendingRequestId}</div>
</section>
</CardFrame>
);
}
if (requestFlowStep === 0) {
return (
<section className={`surface ${wrapClass}`}>
<CardFrame title="Verification" className={wrapClass}>
<div className={innerFlowClass}>
<p className="text-sm text-slate-300">Verification</p>
<button type="button" className="button-dark text-sm" onClick={beginRequestFlow} disabled={working}>
Request Verification
</button>
</div>
</section>
</CardFrame>
);
}
return (
<form className={`surface ${wrapClass}`} onSubmit={handleRequestSubmit}>
<CardFrame title="Request verification" className={wrapClass}>
<form onSubmit={handleRequestSubmit}>
<div className={innerFlowClass}>
<p className="text-sm text-slate-300">Request verification</p>
<p className="text-xs text-slate-500">
Step {requestFlowStep} of 3
</p>
@@ -189,6 +189,7 @@ export default function VipVerificationCard({
</div>
) : null}
</div>
</form>
</form>
</CardFrame>
);
}
+2 -2
View File
@@ -155,8 +155,8 @@ export function SessionProvider({ children }) {
const actions = useMemo(
() => ({
login: (username, password) => emitWithAck('auth:login', { username, password }),
identifySession: ({ cookieUserId, nickname } = {}) =>
emitWithAck('session:identify', { cookieUserId, nickname }),
identifySession: ({ cookieUserId, nickname, overseerEnabled } = {}) =>
emitWithAck('session:identify', { cookieUserId, nickname, overseerEnabled }),
setRole: (role) => emitWithAck('session:setRole', { role }),
requestControl: (roverId, options = {}) =>
emitWithAck('session:requestControl', { roverId, ...options }),
+2 -1
View File
@@ -10,6 +10,7 @@ import {
HORN_HEAT_COOL_PER_SEC,
HORN_HEAT_RESUME_THRESHOLD,
HORN_HEAT_UP_PER_SEC,
HORN_MAX_FREQUENCY,
HORN_MAX_MS,
MANUAL_DOCK_ASSIST_MAX_SPEED,
SONG_DEFAULT_NOTE,
@@ -424,7 +425,7 @@ export function ControlSystemProvider({ children }) {
.map((value) => {
const num = Number(value);
if (!Number.isFinite(num)) return 0;
return num <= 0 ? 0 : Math.min(5000, Math.round(num));
return num <= 0 ? 0 : Math.min(HORN_MAX_FREQUENCY, Math.round(num));
});
return { waveform, freqs: normalized };
}, [hornSettings]);
+1
View File
@@ -22,6 +22,7 @@ export const SONG_DEFAULT_DURATION = 8;
export const SONG_REPEAT_MS = 250;
export const HORN_MAX_MS = 4000;
export const HORN_MAX_FREQUENCY = 2000;
export const HORN_HEAT_UP_PER_SEC = 0.4;
export const HORN_HEAT_COOL_PER_SEC = 0.2;
export const HORN_HEAT_RESUME_THRESHOLD = 0.8;
+22 -36
View File
@@ -1,39 +1,10 @@
// Hook: useFullscreenPrompt
// Purpose: Manages fullscreen prompt visibility and dismissal logic across screen sizes/devices. Scope: Provides reusable fullscreen UX state and action handlers.
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useSettingsNamespace } from '../settings/index.js';
const SESSION_KEY = 'fullscreenPromptDismissed';
const MOBILE_LAYOUTS = new Set(['mobile-portrait', 'mobile-landscape']);
const getStorage = () => {
if (typeof window === 'undefined') return null;
try {
return window.sessionStorage;
} catch {
return null;
}
};
const isDismissed = () => {
const storage = getStorage();
if (!storage) return false;
try {
return storage.getItem(SESSION_KEY) === '1';
} catch {
return false;
}
};
const markDismissed = () => {
const storage = getStorage();
if (!storage) return;
try {
storage.setItem(SESSION_KEY, '1');
} catch {
/* ignore */
}
};
const detectIOS = () => {
if (typeof navigator === 'undefined') return false;
const ua = navigator.userAgent || navigator.platform || '';
@@ -52,20 +23,35 @@ const supportsFullscreen = () => {
};
export function useFullscreenPrompt(layout) {
const { value: pageSettings, save: savePageSettings, status: settingsStatus } = useSettingsNamespace('page', {
fullscreenPromptShown: false,
});
const isMobileLayout = MOBILE_LAYOUTS.has(layout);
const [visible, setVisible] = useState(false);
const [mode, setMode] = useState('native');
const [forceVisible, setForceVisible] = useState(false);
const hasShownPrompt = Boolean(pageSettings?.fullscreenPromptShown);
const isIOS = useMemo(() => detectIOS(), []);
const nativeSupported = useMemo(() => supportsFullscreen(), []);
const markShown = useCallback(() => {
if (settingsStatus !== 'ready') return;
savePageSettings((current) => {
if (current?.fullscreenPromptShown) return current;
return { ...(current || {}), fullscreenPromptShown: true };
});
}, [savePageSettings, settingsStatus]);
const reevaluate = useCallback(() => {
if (settingsStatus !== 'ready') {
setVisible(false);
return;
}
if (!isMobileLayout) {
setVisible(false);
setForceVisible(false);
return;
}
if (!forceVisible && isDismissed()) {
if (!forceVisible && hasShownPrompt) {
setVisible(false);
return;
}
@@ -84,7 +70,7 @@ export function useFullscreenPrompt(layout) {
}
setMode('native');
setVisible(true);
}, [forceVisible, isIOS, isMobileLayout]);
}, [forceVisible, hasShownPrompt, isIOS, isMobileLayout, settingsStatus]);
useEffect(() => {
reevaluate();
@@ -109,10 +95,10 @@ export function useFullscreenPrompt(layout) {
}, [reevaluate]);
const dismiss = useCallback(() => {
markDismissed();
markShown();
setForceVisible(false);
setVisible(false);
}, []);
}, [markShown]);
const enterFullscreen = useCallback(async () => {
if (!isMobileLayout || isIOS) return false;
@@ -131,7 +117,7 @@ export function useFullscreenPrompt(layout) {
if (result && typeof result.then === 'function') {
await result;
}
markDismissed();
markShown();
setForceVisible(false);
setVisible(false);
return true;
@@ -139,7 +125,7 @@ export function useFullscreenPrompt(layout) {
console.warn('Failed to enter fullscreen', error);
return false;
}
}, [isIOS, isMobileLayout]);
}, [isIOS, isMobileLayout, markShown]);
const showPrompt = useCallback(() => {
if (!isMobileLayout) return;
+20 -4
View File
@@ -13,14 +13,20 @@ export default function useUserIdentitySync() {
cookieUserId: '',
});
const { value: profile, status: profileStatus } = useSettingsNamespace('profile', { nickname: '' });
const { value: overseerPreference, status: overseerPreferenceStatus } = useSettingsNamespace(
'overseerPreference',
{ enabled: false },
);
const inFlightRef = useRef(false);
const lastAckSocketRef = useRef(null);
const retryTimerRef = useRef(null);
const ready = identityStatus === 'ready' && profileStatus === 'ready';
const ready =
identityStatus === 'ready' && profileStatus === 'ready' && overseerPreferenceStatus === 'ready';
const cookieUserId = (identity?.cookieUserId || '').trim();
const nickname = (profile?.nickname || '').trim();
const overseerEnabled = Boolean(overseerPreference?.enabled);
const clearRetry = useCallback(() => {
if (retryTimerRef.current) {
@@ -33,7 +39,7 @@ export default function useUserIdentitySync() {
if (!ready || !connected || !socket?.id || inFlightRef.current) return;
inFlightRef.current = true;
try {
const resp = await identifySession({ cookieUserId, nickname });
const resp = await identifySession({ cookieUserId, nickname, overseerEnabled });
const nextKey = (resp?.cookieUserId || '').trim();
if (nextKey && nextKey !== cookieUserId) {
saveIdentity((current) => ({ ...(current || {}), cookieUserId: nextKey }));
@@ -48,7 +54,17 @@ export default function useUserIdentitySync() {
} finally {
inFlightRef.current = false;
}
}, [clearRetry, connected, cookieUserId, identifySession, nickname, ready, saveIdentity, socket?.id]);
}, [
clearRetry,
connected,
cookieUserId,
identifySession,
nickname,
overseerEnabled,
ready,
saveIdentity,
socket?.id,
]);
useEffect(() => {
if (!ready || !connected || !socket?.id) return;
@@ -59,7 +75,7 @@ export default function useUserIdentitySync() {
useEffect(() => {
if (!ready || !connected || !socket?.id) return;
sendIdentify();
}, [ready, connected, socket?.id, cookieUserId, nickname, sendIdentify]);
}, [ready, connected, socket?.id, cookieUserId, nickname, overseerEnabled, sendIdentify]);
useEffect(() => {
const handleOnline = () => {
+15
View File
@@ -0,0 +1,15 @@
export function getSessionSocials(state) {
const socials = state?.session?.socials;
return Array.isArray(socials) ? socials : [];
}
export function getSocialById(state, socialId) {
const key = String(socialId || '').trim().toLowerCase();
if (!key) return null;
return (
getSessionSocials(state).find((entry) => {
const entryKey = String(entry?.id || entry?.label || '').trim().toLowerCase();
return entryKey === key;
}) || null
);
}
+3 -2
View File
@@ -30,9 +30,10 @@ export function SettingsProvider({ children }) {
const setNamespace = useCallback((namespace, updater) => {
setState((prev) => {
const current = prev.data[namespace] || {};
const baseData = prev.status === 'ready' ? prev.data : loadSettings() ?? {};
const current = baseData[namespace] || {};
const nextValue = typeof updater === 'function' ? updater(current) : { ...current, ...(updater ?? {}) };
const nextData = { ...prev.data, [namespace]: nextValue };
const nextData = { ...baseData, [namespace]: nextValue };
const success = saveSettings(nextData);
if (!success) {
return { ...prev, status: 'error' };