mirror of
https://github.com/simstudioai/sim.git
synced 2026-08-29 02:27:35 +08:00
feat(cli): add saved chat commands
This commit is contained in:
@@ -101,16 +101,20 @@ Settings → API keys.
|
||||
|
||||
## Commands
|
||||
|
||||
Plural resource names are canonical, but every plural top-level resource group
|
||||
also accepts its singular form: for example, `sim table list`,
|
||||
Plural resource names are canonical, but most plural top-level resource groups
|
||||
also accept their singular form: for example, `sim table list`,
|
||||
`sim file download`, and `sim workflow get` are equivalent to their plural
|
||||
spellings.
|
||||
spellings. Chats deliberately keep separate names: `sim chat` is the terminal
|
||||
conversation, while `sim chats` manages saved chat resources.
|
||||
|
||||
`knowledge` also accepts the shorter `kb` alias.
|
||||
|
||||
```bash
|
||||
sim chat [prompt...] [-f <path>...] [--read-only]
|
||||
sim chat -p [prompt...] [-f <path>...] [--read-only]
|
||||
sim chats list [--search <text>] [--limit <n>]
|
||||
sim chats get <chatId> [--read-only]
|
||||
sim chats rename <chatId> --title <title>
|
||||
|
||||
sim workflows ls [path] [--search <text>] [--limit <n>]
|
||||
sim workflows list [--folder <path>] [--deployed-only] [--limit <n>]
|
||||
@@ -176,6 +180,8 @@ sim billing logs [--period 7d] [--source sim-chat] [--limit <n>] [--all-workspac
|
||||
The `sim-chat` billing source combines Copilot and workspace chat usage.
|
||||
Organization audit logs require a personal API key. Commands with
|
||||
`--all-workspaces` otherwise default to the workspace in the active profile.
|
||||
Saved chat commands also require a personal API key and use that active
|
||||
workspace unless `--workspace` overrides it.
|
||||
|
||||
`workflows runs get` is the lightweight status and polling resource.
|
||||
`--workflow` names the parent resource, while the run ID remains positional.
|
||||
@@ -230,7 +236,7 @@ a searchable picker. Selecting one restores its transcript and continues it
|
||||
with a fresh opaque token. The header shows the active chat title and keeps the
|
||||
`/chats` switch hint visible; a new chat's generated title appears there as soon
|
||||
as the server publishes it. `/rename <title>` retitles the active synced chat in
|
||||
both the terminal and Sim Home. `/clear` clears the visible transcript and
|
||||
both the terminal and Sim Home. `/new` clears the visible transcript and
|
||||
starts a new conversation, `/help` lists commands, and `/exit` or Ctrl+D exits.
|
||||
Ctrl+C clears idle input or cancels the active generation and returns to the
|
||||
prompt.
|
||||
@@ -254,11 +260,16 @@ pipelines and redirected output must use `-p`.
|
||||
|
||||
```bash
|
||||
sim chat -p "Which workflows handle support tickets?"
|
||||
sim chat -p --chat <chatId> "Continue this conversation"
|
||||
cat incident.txt | sim chat -p "Which workflow is most likely involved?"
|
||||
sim chat -p < question.txt
|
||||
sim chat -p --file report.pdf "Summarize this in workspace context"
|
||||
```
|
||||
|
||||
Pass `--chat <chatId>` to append one print-mode turn to an existing inactive
|
||||
chat, print its answer, and exit. The chat must belong to the active workspace,
|
||||
and synchronized history requires a personal API key.
|
||||
|
||||
When both a positional prompt and stdin are present, the positional prompt comes
|
||||
first and the piped content follows on the next line. This matches Claude Code's
|
||||
print-mode input behavior. Combined input is limited to 10 MiB of UTF-8 text.
|
||||
|
||||
@@ -185,10 +185,10 @@ export function contextSpans(
|
||||
/** Composer slash commands, the source for the `/` menu. */
|
||||
export const SLASH_COMMANDS: SuggestionItem[] = [
|
||||
{
|
||||
id: 'clear',
|
||||
value: '/clear',
|
||||
displayText: '/clear',
|
||||
description: 'start a new conversation',
|
||||
id: 'new',
|
||||
value: '/new',
|
||||
displayText: '/new',
|
||||
description: 'start a new chat',
|
||||
tag: 'command',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -280,6 +280,166 @@ describe('chat print mode', () => {
|
||||
expect(writeOutput).toHaveBeenCalledWith('Hello world')
|
||||
})
|
||||
|
||||
it('resumes an existing chat by ID for one print-mode turn', async () => {
|
||||
mocks.request.mockResolvedValueOnce({
|
||||
data: {
|
||||
id: 'chat-1',
|
||||
title: 'Existing chat',
|
||||
messages: [],
|
||||
continuationToken: 'resume-token',
|
||||
active: false,
|
||||
},
|
||||
})
|
||||
mocks.requestRaw.mockResolvedValue(completed('Continued answer', 'next-token'))
|
||||
const writeOutput = vi.fn()
|
||||
|
||||
await program(async () => '', writeOutput).parseAsync([
|
||||
'node',
|
||||
'sim',
|
||||
'chat',
|
||||
'-p',
|
||||
'--chat',
|
||||
'chat-1',
|
||||
'Continue here',
|
||||
])
|
||||
|
||||
expect(mocks.request).toHaveBeenCalledWith('/api/v2/chats/chat-1', {
|
||||
query: { workspaceId: 'ws_local' },
|
||||
auth: 'optional',
|
||||
})
|
||||
expect(mocks.requestRaw.mock.calls[0][1].body).toEqual({
|
||||
workspaceId: 'ws_local',
|
||||
prompt: 'Continue here',
|
||||
continuationToken: 'resume-token',
|
||||
})
|
||||
expect(writeOutput).toHaveBeenCalledWith('Continued answer')
|
||||
})
|
||||
|
||||
it('binds a resumed print-mode token to read-only mode', async () => {
|
||||
mocks.request.mockResolvedValueOnce({
|
||||
data: {
|
||||
id: 'chat-1',
|
||||
title: 'Existing chat',
|
||||
messages: [],
|
||||
continuationToken: 'read-only-token',
|
||||
active: false,
|
||||
},
|
||||
})
|
||||
mocks.requestRaw.mockResolvedValue(completed('Read-only answer'))
|
||||
|
||||
await program(async () => '').parseAsync([
|
||||
'node',
|
||||
'sim',
|
||||
'chat',
|
||||
'-p',
|
||||
'--read-only',
|
||||
'--chat',
|
||||
'chat-1',
|
||||
'Continue safely',
|
||||
])
|
||||
|
||||
expect(mocks.request).toHaveBeenCalledWith('/api/v2/chats/chat-1', {
|
||||
query: { workspaceId: 'ws_local', readOnly: true },
|
||||
auth: 'optional',
|
||||
})
|
||||
expect(mocks.requestRaw.mock.calls[0][1].body).toEqual({
|
||||
workspaceId: 'ws_local',
|
||||
prompt: 'Continue safely',
|
||||
readOnly: true,
|
||||
continuationToken: 'read-only-token',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects --chat outside print mode', async () => {
|
||||
await expect(
|
||||
program(async () => '', vi.fn(), { isInteractive: () => true }).parseAsync([
|
||||
'node',
|
||||
'sim',
|
||||
'chat',
|
||||
'--chat',
|
||||
'chat-1',
|
||||
])
|
||||
).rejects.toThrow('--chat can only be used with -p/--print')
|
||||
|
||||
expect(mocks.request).not.toHaveBeenCalled()
|
||||
expect(mocks.requestRaw).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not race a print-mode turn into a chat active elsewhere', async () => {
|
||||
mocks.request.mockResolvedValueOnce({
|
||||
data: {
|
||||
id: 'chat-1',
|
||||
title: 'Existing chat',
|
||||
messages: [],
|
||||
continuationToken: 'resume-token',
|
||||
active: true,
|
||||
},
|
||||
})
|
||||
|
||||
await expect(
|
||||
program(async () => '').parseAsync([
|
||||
'node',
|
||||
'sim',
|
||||
'chat',
|
||||
'-p',
|
||||
'--chat',
|
||||
'chat-1',
|
||||
'Continue here',
|
||||
])
|
||||
).rejects.toThrow('currently active in another client')
|
||||
|
||||
expect(mocks.requestRaw).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('surfaces a conflict if the chat becomes active after lookup', async () => {
|
||||
mocks.request.mockResolvedValueOnce({
|
||||
data: {
|
||||
id: 'chat-1',
|
||||
title: 'Existing chat',
|
||||
messages: [],
|
||||
continuationToken: 'resume-token',
|
||||
active: false,
|
||||
},
|
||||
})
|
||||
mocks.requestRaw.mockRejectedValueOnce(
|
||||
new SimApiError('A response is already in progress for this chat', 409, 'CONFLICT')
|
||||
)
|
||||
const writeOutput = vi.fn()
|
||||
|
||||
await expect(
|
||||
program(async () => '', writeOutput).parseAsync([
|
||||
'node',
|
||||
'sim',
|
||||
'chat',
|
||||
'-p',
|
||||
'--chat',
|
||||
'chat-1',
|
||||
'Continue here',
|
||||
])
|
||||
).rejects.toThrow('A response is already in progress for this chat')
|
||||
|
||||
expect(mocks.requestRaw).toHaveBeenCalledOnce()
|
||||
expect(writeOutput).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('surfaces an inaccessible chat without starting a new one', async () => {
|
||||
mocks.request.mockRejectedValueOnce(new SimApiError('Chat not found', 404, 'NOT_FOUND'))
|
||||
|
||||
await expect(
|
||||
program(async () => '').parseAsync([
|
||||
'node',
|
||||
'sim',
|
||||
'chat',
|
||||
'-p',
|
||||
'--chat',
|
||||
'missing-chat',
|
||||
'Continue here',
|
||||
])
|
||||
).rejects.toThrow('Chat not found')
|
||||
|
||||
expect(mocks.requestRaw).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the profile shorthand distinct from chat -p', async () => {
|
||||
mocks.requestRaw.mockResolvedValue(completed('answer'))
|
||||
|
||||
@@ -1052,7 +1212,7 @@ describe('interactive chat', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('visibly resets the transcript and continuation identity with /clear', async () => {
|
||||
it('visibly resets the transcript and continuation identity with /new', async () => {
|
||||
mocks.requestRaw
|
||||
.mockResolvedValueOnce(
|
||||
sse([
|
||||
@@ -1062,7 +1222,7 @@ describe('interactive chat', () => {
|
||||
)
|
||||
.mockResolvedValueOnce(completed('Second', 'token-2'))
|
||||
const terminal = new FakeTerminal([
|
||||
{ kind: 'line', value: '/clear' },
|
||||
{ kind: 'line', value: '/new' },
|
||||
{ kind: 'line', value: 'Fresh question' },
|
||||
{ kind: 'line', value: '/exit' },
|
||||
])
|
||||
@@ -1998,7 +2158,7 @@ describe('interactive chat', () => {
|
||||
it('does not move queued prompts into another conversation', async () => {
|
||||
const terminal = new FakeTerminal([
|
||||
{ kind: 'line', value: 'first' },
|
||||
{ kind: 'line', value: '/clear', queued: true, display: '/clear' },
|
||||
{ kind: 'line', value: '/new', queued: true, display: '/new' },
|
||||
{ kind: 'line', value: 'second', queued: true, display: 'second' },
|
||||
{ kind: 'line', value: '/chats', queued: true, display: '/chats' },
|
||||
{ kind: 'line', value: 'third', queued: true, display: 'third' },
|
||||
|
||||
@@ -332,7 +332,8 @@ async function runOneShot(
|
||||
prompt: string,
|
||||
attachments: ChatAttachment[],
|
||||
readOnly: boolean,
|
||||
dependencies: ChatDependencies
|
||||
dependencies: ChatDependencies,
|
||||
continuationToken?: string
|
||||
): Promise<void> {
|
||||
const controller = new AbortController()
|
||||
const cancel = () => controller.abort()
|
||||
@@ -345,6 +346,7 @@ async function runOneShot(
|
||||
workspaceId,
|
||||
prompt,
|
||||
...(readOnly ? { readOnly: true } : {}),
|
||||
...(continuationToken ? { continuationToken } : {}),
|
||||
...(attachments.length ? { attachments } : {}),
|
||||
},
|
||||
controller.signal
|
||||
@@ -373,7 +375,7 @@ type UserTurnResult =
|
||||
pastes?: ReadonlyMap<number, string>
|
||||
contexts?: ChatContext[]
|
||||
}
|
||||
| { kind: 'clear'; attachments: ChatAttachment[] }
|
||||
| { kind: 'new'; attachments: ChatAttachment[] }
|
||||
| { kind: 'chats'; attachments: ChatAttachment[] }
|
||||
| { kind: 'rename'; title: string; attachments: ChatAttachment[] }
|
||||
| { kind: 'idle'; attachments: ChatAttachment[] }
|
||||
@@ -385,7 +387,7 @@ function explainInteractiveCommands(terminal: ChatTerminal): void {
|
||||
'Commands:',
|
||||
' ctrl+v attach the clipboard image or file (or cmd+v on macOS)',
|
||||
' <file path> drop or type a path to attach the file',
|
||||
' /clear start a new conversation',
|
||||
' /new start a new chat',
|
||||
' /chats view and switch chats',
|
||||
' /rename <title> rename the active chat',
|
||||
' /help show this help',
|
||||
@@ -466,7 +468,7 @@ async function readUserTurn(
|
||||
explainInteractiveCommands(terminal)
|
||||
continue
|
||||
}
|
||||
if (trimmed === '/clear') return { kind: 'clear', attachments }
|
||||
if (trimmed === '/new') return { kind: 'new', attachments }
|
||||
if (trimmed === '/chats') {
|
||||
return { kind: 'chats', attachments }
|
||||
}
|
||||
@@ -1013,11 +1015,11 @@ async function runInteractive(
|
||||
nextPromptConflictRetries = 0
|
||||
continue
|
||||
}
|
||||
if ((input.kind === 'clear' || input.kind === 'chats') && terminal.hasQueuedInput()) {
|
||||
if ((input.kind === 'new' || input.kind === 'chats') && terminal.hasQueuedInput()) {
|
||||
terminal.status('Finish queued prompts before changing conversations.')
|
||||
continue
|
||||
}
|
||||
if (input.kind === 'clear') {
|
||||
if (input.kind === 'new') {
|
||||
startNewConversation()
|
||||
continue
|
||||
}
|
||||
@@ -1470,18 +1472,30 @@ export function chatCommand(overrides: Partial<ChatDependencies> = {}): Command
|
||||
.description('Ask Sim Chat about the active workspace')
|
||||
.argument('[prompt...]', 'Question to ask')
|
||||
.option('-p, --print', 'Print the final response and exit')
|
||||
.option('--chat <chatId>', 'Resume an existing chat by ID (print mode only)')
|
||||
.option('-f, --file <path>', 'Attach a local file (repeatable)', collectFile, [])
|
||||
.option('--read-only', 'Restrict Sim Chat to read-only workspace tools')
|
||||
.action(
|
||||
async (
|
||||
promptParts: string[],
|
||||
options: { print?: boolean; file: string[]; readOnly?: boolean },
|
||||
options: { print?: boolean; chat?: string; file: string[]; readOnly?: boolean },
|
||||
command: Command
|
||||
) => {
|
||||
const positionalPrompt = promptParts.join(' ')
|
||||
const positionalBytes = utf8Bytes(positionalPrompt)
|
||||
if (positionalBytes > MAX_CHAT_PROMPT_BYTES) throw inputTooLarge()
|
||||
|
||||
const chatId = options.chat?.trim()
|
||||
if (options.chat !== undefined && !chatId) {
|
||||
throw new SimApiError('Chat ID must not be empty.', 0)
|
||||
}
|
||||
if (chatId && !options.print) {
|
||||
throw new SimApiError(
|
||||
'--chat can only be used with -p/--print. Use /chats in interactive mode.',
|
||||
0
|
||||
)
|
||||
}
|
||||
|
||||
const interactive = !options.print && dependencies.isInteractive()
|
||||
if (!options.print && !interactive) {
|
||||
throw new SimApiError(
|
||||
@@ -1515,13 +1529,32 @@ export function chatCommand(overrides: Partial<ChatDependencies> = {}): Command
|
||||
)
|
||||
return
|
||||
}
|
||||
let continuationToken: string | undefined
|
||||
if (chatId) {
|
||||
const chat = await loadChat(client, workspaceId, chatId, options.readOnly === true)
|
||||
if (!chat.continuationToken) {
|
||||
throw new SimApiError(
|
||||
'Sim Chat did not return a continuation token for the selected chat.',
|
||||
0
|
||||
)
|
||||
}
|
||||
if (chat.active) {
|
||||
throw new SimApiError(
|
||||
'The selected chat is currently active in another client. Wait for it to finish before resuming it.',
|
||||
409,
|
||||
'CONFLICT'
|
||||
)
|
||||
}
|
||||
continuationToken = chat.continuationToken
|
||||
}
|
||||
await runOneShot(
|
||||
client,
|
||||
workspaceId,
|
||||
prompt,
|
||||
attachments,
|
||||
options.readOnly === true,
|
||||
dependencies
|
||||
dependencies,
|
||||
continuationToken
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -292,6 +292,37 @@ export const CLI_CONTRACT: CliContract = {
|
||||
updateCustomTool: { flags: { schema: { json: true, describe: CUSTOM_TOOL_SCHEMA_HELP } } },
|
||||
|
||||
// ─── Output columns for list commands ─────────────────────────────────────
|
||||
listChats: {
|
||||
flags: { search: { describe: 'Filter chats by title' } },
|
||||
columns: [
|
||||
{ header: 'id' },
|
||||
{ header: 'title' },
|
||||
{ header: 'updated', path: 'updatedAt', format: 'timestamp' },
|
||||
{ header: 'pinned', format: 'bool' },
|
||||
{ header: 'active', format: 'bool' },
|
||||
],
|
||||
},
|
||||
getChat: {
|
||||
describe: 'Show chat metadata and message count',
|
||||
flags: {
|
||||
readOnly: {
|
||||
boolean: true,
|
||||
describe: 'Bind the returned continuation token to read-only mode',
|
||||
},
|
||||
},
|
||||
fields: [
|
||||
{ header: 'id' },
|
||||
{ header: 'title' },
|
||||
{ header: 'messages', format: 'count' },
|
||||
{ header: 'active', format: 'bool' },
|
||||
],
|
||||
},
|
||||
renameChat: {
|
||||
command: 'chats rename',
|
||||
describe: 'Rename a chat',
|
||||
flags: { title: { describe: 'New chat title' } },
|
||||
fields: [{ header: 'id' }, { header: 'title' }],
|
||||
},
|
||||
listTables: {
|
||||
flags: { folderPath: FOLDER_PATH_FLAG },
|
||||
columns: [
|
||||
|
||||
@@ -252,6 +252,9 @@ describe('generated operation table', () => {
|
||||
'getLog',
|
||||
'getBillingStatus',
|
||||
'listBillingLogs',
|
||||
'listChats',
|
||||
'getChat',
|
||||
'renameChat',
|
||||
'listWorkflowRuns',
|
||||
'getWorkflowRun',
|
||||
'resumeWorkflow',
|
||||
|
||||
@@ -108,6 +108,56 @@ describe('commands parsed through commander', () => {
|
||||
expect(program().commands.some((command) => command.name() === 'folders')).toBe(false)
|
||||
})
|
||||
|
||||
it('exposes saved chats through the generated resource commands', async () => {
|
||||
expect(
|
||||
commandAt('chats')
|
||||
.commands.map((command) => command.name())
|
||||
.sort()
|
||||
).toEqual(['get', 'list', 'rename'])
|
||||
|
||||
const listHelp = commandAt('chats', 'list').helpInformation()
|
||||
expect(listHelp).toContain('--search <value>')
|
||||
expect(listHelp).toContain('Filter chats by title')
|
||||
expect(listHelp).toContain('--limit <n>')
|
||||
|
||||
const [listPath, listOptions] = await run([
|
||||
'chats',
|
||||
'list',
|
||||
'--search',
|
||||
'incident',
|
||||
'--limit',
|
||||
'5',
|
||||
])
|
||||
expect(listPath).toBe('/api/v2/chats')
|
||||
expect(listOptions.query).toMatchObject({
|
||||
workspaceId: 'ws_local',
|
||||
search: 'incident',
|
||||
limit: 5,
|
||||
})
|
||||
|
||||
const getHelp = commandAt('chats', 'get').helpInformation()
|
||||
expect(getHelp).toContain('Bind the returned continuation token to read-only mode')
|
||||
const [getPath, getOptions] = await run(['chats', 'get', 'chat_1', '--read-only'])
|
||||
expect(getPath).toBe('/api/v2/chats/chat_1')
|
||||
expect(getOptions.query).toEqual({ workspaceId: 'ws_local', readOnly: true })
|
||||
|
||||
const renameHelp = commandAt('chats', 'rename').helpInformation()
|
||||
expect(renameHelp).toContain('--title <value>')
|
||||
expect(renameHelp).toContain('New chat title')
|
||||
const [renamePath, renameOptions] = await run([
|
||||
'chats',
|
||||
'rename',
|
||||
'chat_1',
|
||||
'--title',
|
||||
'Incident review',
|
||||
])
|
||||
expect(renamePath).toBe('/api/v2/chats/chat_1')
|
||||
expect(renameOptions).toMatchObject({
|
||||
method: 'PATCH',
|
||||
body: { workspaceId: 'ws_local', title: 'Incident review' },
|
||||
})
|
||||
})
|
||||
|
||||
it('describes generated resource and sub-resource groups', () => {
|
||||
expect(commandAt('tables').description()).toBe('Manage tables')
|
||||
expect(commandAt('tables', 'rows').description()).toBe('Manage table rows')
|
||||
@@ -754,6 +804,32 @@ describe('single-resource rendering', () => {
|
||||
expect(JSON.parse(printed[0])).toEqual({ row: { id: 'r1' }, operation: 'inserted' })
|
||||
})
|
||||
|
||||
it('keeps chat implementation details out of human output', async () => {
|
||||
const chat = {
|
||||
id: 'chat_1',
|
||||
title: 'Incident review',
|
||||
messages: [
|
||||
{ id: 'message_1', role: 'user', content: 'Private prompt', timestamp: '2026-08-04' },
|
||||
{
|
||||
id: 'message_2',
|
||||
role: 'assistant',
|
||||
content: 'Private answer',
|
||||
timestamp: '2026-08-04',
|
||||
},
|
||||
],
|
||||
continuationToken: 'opaque-token',
|
||||
active: false,
|
||||
}
|
||||
|
||||
const human = await lines(['chats', 'get', 'chat_1'], chat, 'text')
|
||||
expect(human).toEqual(['id\tchat_1', 'title\tIncident review', 'messages\t2', 'active\tno'])
|
||||
expect(human.join('\n')).not.toContain('opaque-token')
|
||||
expect(human.join('\n')).not.toContain('Private prompt')
|
||||
|
||||
const machine = await lines(['chats', 'get', 'chat_1'], chat, 'json')
|
||||
expect(JSON.parse(machine[0])).toEqual(chat)
|
||||
})
|
||||
|
||||
it('keeps sensitive run detail opt-in for human log output', async () => {
|
||||
const log = {
|
||||
runId: 'run_1',
|
||||
@@ -850,6 +926,23 @@ describe('contract-selected list rendering', () => {
|
||||
expect(printed).toEqual(['0.91\tpolicy.md\t2\tRefunds are available for 30 days.'])
|
||||
})
|
||||
|
||||
it('formats saved chats like other resource lists', async () => {
|
||||
const printed = await lines(
|
||||
['chats', 'list'],
|
||||
[
|
||||
{
|
||||
id: 'chat_1',
|
||||
title: 'Incident review',
|
||||
updatedAt: '2026-08-04T12:34:56.789Z',
|
||||
pinned: false,
|
||||
active: true,
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
expect(printed).toEqual(['chat_1\tIncident review\t2026-08-04 12:34:56\tno\tyes'])
|
||||
})
|
||||
|
||||
it('renders row matches as rows', async () => {
|
||||
const printed = await lines(['tables', 'rows', 'find', 'tbl_1', '--q', 'alice'], {
|
||||
matches: [{ ordinal: 3, rowId: 'row_1', column: 'email' }],
|
||||
|
||||
Reference in New Issue
Block a user