Compare commits
1 Commits
8be68d4c87
...
46cad0ec7c
| Author | SHA1 | Date | |
|---|---|---|---|
| 46cad0ec7c |
@@ -25,6 +25,9 @@ UI; it is served on the web and **bundled unchanged** into `Fabric.Desktop`
|
||||
iframe / plain text), live via `canvas.*` sockets; sharer-only edit/remove.
|
||||
- **Right-click menus** — native menu overridden with resource-specific
|
||||
context menus (guild / channel / message / user / blank areas).
|
||||
- **Slash autocomplete** — typing `/` opens a command panel from the
|
||||
guild's synced OpenClaw catalog (`GET /api/commands`): filter, ↑↓/Enter/
|
||||
Esc, pick inserts `/<name> `, arg stage shows args + clickable choices.
|
||||
- **Dev mode** — surfaces guild `/ack` and per-message `wakeup` metadata
|
||||
(off by default; persisted in `localStorage`).
|
||||
- Routing is `BrowserRouter` on the web and `HashRouter` under `file://`
|
||||
|
||||
@@ -995,3 +995,88 @@ textarea.canvas-src {
|
||||
max-width: 720px;
|
||||
width: 92vw;
|
||||
}
|
||||
|
||||
/* ---- slash command autocomplete ---- */
|
||||
.slash-panel {
|
||||
border: 1px solid var(--border);
|
||||
border-bottom: 0;
|
||||
background: var(--elevated);
|
||||
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
padding: 6px;
|
||||
}
|
||||
.slash-panel .slash-hint {
|
||||
font-size: 11px;
|
||||
color: var(--text-faint);
|
||||
padding: 4px 8px 6px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.slash-item {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: baseline;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: 0;
|
||||
color: var(--text);
|
||||
padding: 7px 8px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.slash-item.sel,
|
||||
.slash-item:hover {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.slash-item .sc-name {
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
color: var(--text-h);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sc-desc {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.slash-arg {
|
||||
padding: 6px 8px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.slash-arg .sa-name {
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
color: var(--text-h);
|
||||
}
|
||||
.slash-arg .sa-req {
|
||||
color: var(--danger);
|
||||
margin-left: 1px;
|
||||
}
|
||||
.slash-arg .sa-type {
|
||||
font-size: 11px;
|
||||
color: var(--text-faint);
|
||||
margin: 0 8px;
|
||||
}
|
||||
.sa-choices {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.sa-choice {
|
||||
font-size: 12px;
|
||||
background: var(--input);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.sa-choice:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import axios from 'axios'
|
||||
import { io, type Socket } from 'socket.io-client'
|
||||
import { useEffect, useMemo, useState, type MouseEvent as ReactMouseEvent } from 'react'
|
||||
import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
type KeyboardEvent as ReactKeyboardEvent,
|
||||
} from 'react'
|
||||
import { useAuth } from '../auth/auth-context'
|
||||
import { renderMarkdown } from '../lib/markdown'
|
||||
import { guildMembersCenter, joinGuildCenter } from '../lib/center-auth-client'
|
||||
@@ -20,6 +26,23 @@ type MessageItem = {
|
||||
}
|
||||
|
||||
type CanvasFormat = 'md' | 'html' | 'text'
|
||||
type SlashArg = {
|
||||
name: string
|
||||
description: string
|
||||
type: string
|
||||
required: boolean
|
||||
captureRemaining: boolean
|
||||
choices: Array<{ value: string; label: string }> | null
|
||||
}
|
||||
type SlashCommand = {
|
||||
name: string
|
||||
nativeName: string
|
||||
description: string
|
||||
acceptsArgs: boolean
|
||||
args: SlashArg[]
|
||||
argsParsing: string
|
||||
}
|
||||
|
||||
type CanvasDoc = {
|
||||
channelId: string
|
||||
sharerUserId: string
|
||||
@@ -92,6 +115,8 @@ export default function ChatPage() {
|
||||
const [canvasFormat, setCanvasFormat] = useState<CanvasFormat>('md')
|
||||
const [canvasSource, setCanvasSource] = useState('')
|
||||
const [canvasEditMode, setCanvasEditMode] = useState(false)
|
||||
const [slashCmds, setSlashCmds] = useState<SlashCommand[]>([])
|
||||
const [slashSel, setSlashSel] = useState(0)
|
||||
|
||||
function openCtx(e: ReactMouseEvent, title: string, items: MenuItem[]) {
|
||||
e.preventDefault()
|
||||
@@ -281,6 +306,20 @@ export default function ChatPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCommands() {
|
||||
if (!guild || !guildToken) {
|
||||
setSlashCmds([])
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await guildApi().get('/commands')
|
||||
const list = Array.isArray(res.data?.commands) ? (res.data.commands as SlashCommand[]) : []
|
||||
setSlashCmds(list)
|
||||
} catch {
|
||||
setSlashCmds([])
|
||||
}
|
||||
}
|
||||
|
||||
function openCanvasShare() {
|
||||
setCanvasEditMode(false)
|
||||
setCanvasTitle('')
|
||||
@@ -490,6 +529,7 @@ export default function ChatPage() {
|
||||
|
||||
useEffect(() => {
|
||||
void loadChannels()
|
||||
void loadCommands()
|
||||
setMessages([])
|
||||
setSelectedChannelId('')
|
||||
}, [selectedGuildId, guildDbId, guildToken])
|
||||
@@ -768,6 +808,117 @@ export default function ChatPage() {
|
||||
)
|
||||
}
|
||||
|
||||
// ---- slash-command autocomplete ----
|
||||
const slashNameMatch = /^\/(\S*)$/.exec(content)
|
||||
const slashQuery = slashNameMatch ? slashNameMatch[1].toLowerCase() : ''
|
||||
const slashMatches =
|
||||
slashNameMatch && content.startsWith('/')
|
||||
? slashCmds
|
||||
.filter((c) => c.name.toLowerCase().includes(slashQuery))
|
||||
.sort((a, b) => {
|
||||
const ap = a.name.toLowerCase().startsWith(slashQuery) ? 0 : 1
|
||||
const bp = b.name.toLowerCase().startsWith(slashQuery) ? 0 : 1
|
||||
return ap - bp || a.name.localeCompare(b.name)
|
||||
})
|
||||
.slice(0, 8)
|
||||
: []
|
||||
const showSlashNames = slashMatches.length > 0
|
||||
const slashArgMatch = /^\/(\S+)\s+/.exec(content)
|
||||
const slashActiveCmd =
|
||||
!slashNameMatch && slashArgMatch
|
||||
? slashCmds.find(
|
||||
(c) => c.nativeName === slashArgMatch[1] || c.name === slashArgMatch[1],
|
||||
) ?? null
|
||||
: null
|
||||
const showSlashArgs = !!slashActiveCmd && slashActiveCmd.acceptsArgs
|
||||
|
||||
function pickSlash(cmd: SlashCommand) {
|
||||
setContent(`/${cmd.nativeName} `)
|
||||
setSlashSel(0)
|
||||
}
|
||||
function appendSlashValue(v: string) {
|
||||
setContent((c) => `${c.replace(/\s*$/, '')} ${v} `)
|
||||
}
|
||||
function onComposerKey(e: ReactKeyboardEvent<HTMLInputElement>) {
|
||||
if (!showSlashNames) return
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setSlashSel((s) => Math.min(s + 1, slashMatches.length - 1))
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setSlashSel((s) => Math.max(s - 1, 0))
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
pickSlash(slashMatches[slashSel] ?? slashMatches[0])
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
setContent('')
|
||||
}
|
||||
}
|
||||
|
||||
function renderSlashPanel() {
|
||||
if (showSlashNames) {
|
||||
return (
|
||||
<div className="slash-panel">
|
||||
<div className="slash-hint">commands · ↑↓ select · Enter pick · Esc clear</div>
|
||||
{slashMatches.map((c, i) => (
|
||||
<button
|
||||
key={c.name}
|
||||
type="button"
|
||||
className={`slash-item ${i === slashSel ? 'sel' : ''}`}
|
||||
onMouseEnter={() => setSlashSel(i)}
|
||||
onClick={() => pickSlash(c)}
|
||||
>
|
||||
<span className="sc-name">/{c.nativeName}</span>
|
||||
<span className="sc-desc">{c.description}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (showSlashArgs && slashActiveCmd) {
|
||||
return (
|
||||
<div className="slash-panel">
|
||||
<div className="slash-hint">
|
||||
/{slashActiveCmd.nativeName} — {slashActiveCmd.description}
|
||||
</div>
|
||||
{slashActiveCmd.args.length === 0 ? (
|
||||
<div className="slash-arg">(no arguments)</div>
|
||||
) : (
|
||||
slashActiveCmd.args.map((a) => (
|
||||
<div key={a.name} className="slash-arg">
|
||||
<div>
|
||||
<span className="sa-name">
|
||||
{a.name}
|
||||
{a.required ? <span className="sa-req">*</span> : null}
|
||||
</span>
|
||||
<span className="sa-type">{a.type}</span>
|
||||
<span className="sc-desc">{a.description}</span>
|
||||
</div>
|
||||
{a.choices && a.choices.length ? (
|
||||
<div className="sa-choices">
|
||||
{a.choices.slice(0, 24).map((ch) => (
|
||||
<button
|
||||
key={ch.value}
|
||||
type="button"
|
||||
className="sa-choice"
|
||||
onClick={() => appendSlashValue(ch.value)}
|
||||
title={ch.label}
|
||||
>
|
||||
{ch.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dc-shell" onContextMenu={(e) => openCtx(e, 'Fabric', blankMenu('root'))}>
|
||||
<nav className="dc-rail" onContextMenu={(e) => openCtx(e, 'Guilds', blankMenu('guilds'))}>
|
||||
@@ -1009,6 +1160,7 @@ export default function ChatPage() {
|
||||
<div className="dc-closed-banner">This channel is closed — history is read-only.</div>
|
||||
) : (
|
||||
<div className="dc-composer-wrap">
|
||||
{renderSlashPanel()}
|
||||
{pendingFiles.length ? (
|
||||
<div className="pending-files">
|
||||
{pendingFiles.map((f, fi) => (
|
||||
@@ -1054,7 +1206,11 @@ export default function ChatPage() {
|
||||
<input
|
||||
className="input"
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setContent(e.target.value)
|
||||
setSlashSel(0)
|
||||
}}
|
||||
onKeyDown={onComposerKey}
|
||||
placeholder={currentChannel ? `Message #${currentChannel.name}` : 'Select a channel first'}
|
||||
disabled={!currentChannel}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user