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.
|
iframe / plain text), live via `canvas.*` sockets; sharer-only edit/remove.
|
||||||
- **Right-click menus** — native menu overridden with resource-specific
|
- **Right-click menus** — native menu overridden with resource-specific
|
||||||
context menus (guild / channel / message / user / blank areas).
|
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
|
- **Dev mode** — surfaces guild `/ack` and per-message `wakeup` metadata
|
||||||
(off by default; persisted in `localStorage`).
|
(off by default; persisted in `localStorage`).
|
||||||
- Routing is `BrowserRouter` on the web and `HashRouter` under `file://`
|
- Routing is `BrowserRouter` on the web and `HashRouter` under `file://`
|
||||||
|
|||||||
@@ -995,3 +995,88 @@ textarea.canvas-src {
|
|||||||
max-width: 720px;
|
max-width: 720px;
|
||||||
width: 92vw;
|
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 axios from 'axios'
|
||||||
import { io, type Socket } from 'socket.io-client'
|
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 { useAuth } from '../auth/auth-context'
|
||||||
import { renderMarkdown } from '../lib/markdown'
|
import { renderMarkdown } from '../lib/markdown'
|
||||||
import { guildMembersCenter, joinGuildCenter } from '../lib/center-auth-client'
|
import { guildMembersCenter, joinGuildCenter } from '../lib/center-auth-client'
|
||||||
@@ -20,6 +26,23 @@ type MessageItem = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type CanvasFormat = 'md' | 'html' | 'text'
|
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 = {
|
type CanvasDoc = {
|
||||||
channelId: string
|
channelId: string
|
||||||
sharerUserId: string
|
sharerUserId: string
|
||||||
@@ -92,6 +115,8 @@ export default function ChatPage() {
|
|||||||
const [canvasFormat, setCanvasFormat] = useState<CanvasFormat>('md')
|
const [canvasFormat, setCanvasFormat] = useState<CanvasFormat>('md')
|
||||||
const [canvasSource, setCanvasSource] = useState('')
|
const [canvasSource, setCanvasSource] = useState('')
|
||||||
const [canvasEditMode, setCanvasEditMode] = useState(false)
|
const [canvasEditMode, setCanvasEditMode] = useState(false)
|
||||||
|
const [slashCmds, setSlashCmds] = useState<SlashCommand[]>([])
|
||||||
|
const [slashSel, setSlashSel] = useState(0)
|
||||||
|
|
||||||
function openCtx(e: ReactMouseEvent, title: string, items: MenuItem[]) {
|
function openCtx(e: ReactMouseEvent, title: string, items: MenuItem[]) {
|
||||||
e.preventDefault()
|
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() {
|
function openCanvasShare() {
|
||||||
setCanvasEditMode(false)
|
setCanvasEditMode(false)
|
||||||
setCanvasTitle('')
|
setCanvasTitle('')
|
||||||
@@ -490,6 +529,7 @@ export default function ChatPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadChannels()
|
void loadChannels()
|
||||||
|
void loadCommands()
|
||||||
setMessages([])
|
setMessages([])
|
||||||
setSelectedChannelId('')
|
setSelectedChannelId('')
|
||||||
}, [selectedGuildId, guildDbId, guildToken])
|
}, [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 (
|
return (
|
||||||
<div className="dc-shell" onContextMenu={(e) => openCtx(e, 'Fabric', blankMenu('root'))}>
|
<div className="dc-shell" onContextMenu={(e) => openCtx(e, 'Fabric', blankMenu('root'))}>
|
||||||
<nav className="dc-rail" onContextMenu={(e) => openCtx(e, 'Guilds', blankMenu('guilds'))}>
|
<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-closed-banner">This channel is closed — history is read-only.</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="dc-composer-wrap">
|
<div className="dc-composer-wrap">
|
||||||
|
{renderSlashPanel()}
|
||||||
{pendingFiles.length ? (
|
{pendingFiles.length ? (
|
||||||
<div className="pending-files">
|
<div className="pending-files">
|
||||||
{pendingFiles.map((f, fi) => (
|
{pendingFiles.map((f, fi) => (
|
||||||
@@ -1054,7 +1206,11 @@ export default function ChatPage() {
|
|||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
value={content}
|
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'}
|
placeholder={currentChannel ? `Message #${currentChannel.name}` : 'Select a channel first'}
|
||||||
disabled={!currentChannel}
|
disabled={!currentChannel}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user