Add Rojo system: settings panel, tunnel endpoint, character portrait, fun name generation
This commit is contained in:
parent
93f8c7bb33
commit
a061df2a66
|
|
@ -17,6 +17,22 @@ rojo-chat-panel {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rcp-picker {
|
||||||
|
flex: 1;
|
||||||
|
background: #1a1d2a;
|
||||||
|
color: #dde0f0;
|
||||||
|
border: 1px solid #2a2d3a;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rcp-picker:focus { border-color: #3a4a8a; }
|
||||||
|
|
||||||
.rcp-toolbar-icon {
|
.rcp-toolbar-icon {
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { Editor } from '../../editor/Editor.js';
|
||||||
import { ContextMenuDirectory, ContextMenuReadOnlyEntry } from '../context-menu/context-menu.js';
|
import { ContextMenuDirectory, ContextMenuReadOnlyEntry } from '../context-menu/context-menu.js';
|
||||||
|
|
||||||
declare const markdownit: ( options?: Record<string, unknown> ) => { render: ( md: string ) => string };
|
declare const markdownit: ( options?: Record<string, unknown> ) => { render: ( md: string ) => string };
|
||||||
|
|
@ -12,12 +13,22 @@ function extractRawText( node: Node ): string
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RojoEntry { id: string; name: string; description: string; path: string; }
|
||||||
|
|
||||||
|
function parentDir( filePath: string ): string
|
||||||
|
{
|
||||||
|
const slash = filePath.lastIndexOf( '/' );
|
||||||
|
return slash >= 0 ? filePath.slice( 0, slash ) : 'workspace/rojos';
|
||||||
|
}
|
||||||
|
|
||||||
class RojoChatPanel extends HTMLElement
|
class RojoChatPanel extends HTMLElement
|
||||||
{
|
{
|
||||||
_initialized = false;
|
_initialized = false;
|
||||||
_conversationId: string = '';
|
_conversationId = '';
|
||||||
_sending = false;
|
_sending = false;
|
||||||
_md: { render: ( s: string ) => string } | null = null;
|
_md: { render: ( s: string ) => string } | null = null;
|
||||||
|
_rojos: RojoEntry[] = [];
|
||||||
|
_selectedPath = '';
|
||||||
|
|
||||||
connectedCallback(): void
|
connectedCallback(): void
|
||||||
{
|
{
|
||||||
|
|
@ -31,9 +42,11 @@ class RojoChatPanel extends HTMLElement
|
||||||
this.innerHTML = `
|
this.innerHTML = `
|
||||||
<div class="rcp-toolbar">
|
<div class="rcp-toolbar">
|
||||||
<div class="rcp-toolbar-icon">🤖</div>
|
<div class="rcp-toolbar-icon">🤖</div>
|
||||||
<span class="rcp-toolbar-name">Rojo</span>
|
<select class="rcp-picker" title="Select Rojo">
|
||||||
<button class="rcp-toolbar-btn" title="Actions">★</button>
|
<option value="">— no rojo selected —</option>
|
||||||
<button class="rcp-toolbar-btn" title="Settings">☰</button>
|
</select>
|
||||||
|
<button class="rcp-toolbar-btn rcp-new-btn" title="New Rojo">+</button>
|
||||||
|
<button class="rcp-toolbar-btn rcp-refresh-btn" title="Refresh rojo list">↻</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="rcp-history"></div>
|
<div class="rcp-history"></div>
|
||||||
<div class="rcp-input-area">
|
<div class="rcp-input-area">
|
||||||
|
|
@ -47,17 +60,92 @@ class RojoChatPanel extends HTMLElement
|
||||||
|
|
||||||
const sendBtn = this.querySelector( '.rcp-send-btn' ) as HTMLButtonElement;
|
const sendBtn = this.querySelector( '.rcp-send-btn' ) as HTMLButtonElement;
|
||||||
const inputText = this.querySelector( '.rcp-input-text' ) as HTMLElement;
|
const inputText = this.querySelector( '.rcp-input-text' ) as HTMLElement;
|
||||||
|
const picker = this.querySelector( '.rcp-picker' ) as HTMLSelectElement;
|
||||||
|
|
||||||
sendBtn.addEventListener( 'click', () => this._send() );
|
sendBtn.addEventListener( 'click', () => this._send() );
|
||||||
|
|
||||||
inputText.addEventListener( 'keydown', ( e: KeyboardEvent ) =>
|
inputText.addEventListener( 'keydown', ( e: KeyboardEvent ) =>
|
||||||
{
|
{
|
||||||
if ( e.key === 'Enter' && !e.shiftKey )
|
if ( e.key === 'Enter' && !e.shiftKey ) { e.preventDefault(); this._send(); }
|
||||||
{
|
|
||||||
e.preventDefault();
|
|
||||||
this._send();
|
|
||||||
}
|
|
||||||
} );
|
} );
|
||||||
|
|
||||||
|
picker.addEventListener( 'change', () =>
|
||||||
|
{
|
||||||
|
this._selectedPath = picker.value;
|
||||||
|
} );
|
||||||
|
|
||||||
|
this.querySelector( '.rcp-new-btn' )!.addEventListener( 'click', () => this._createRojo() );
|
||||||
|
this.querySelector( '.rcp-refresh-btn' )!.addEventListener( 'click', () => this._loadRojos() );
|
||||||
|
|
||||||
|
this._loadRojos();
|
||||||
|
}
|
||||||
|
|
||||||
|
async _loadRojos(): Promise<void>
|
||||||
|
{
|
||||||
|
const projectId = Editor.get().projectId;
|
||||||
|
if ( !projectId ) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const res = await fetch( `/api/rojos/${ projectId }/list` );
|
||||||
|
if ( !res.ok ) return;
|
||||||
|
this._rojos = await res.json() as RojoEntry[];
|
||||||
|
this._renderPicker();
|
||||||
|
}
|
||||||
|
catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderPicker(): void
|
||||||
|
{
|
||||||
|
const picker = this.querySelector( '.rcp-picker' ) as HTMLSelectElement;
|
||||||
|
const prev = this._selectedPath;
|
||||||
|
|
||||||
|
picker.innerHTML = '<option value="">— no rojo selected —</option>' +
|
||||||
|
this._rojos.map( r =>
|
||||||
|
`<option value="${ r.path }">${ r.name || r.path.slice( r.path.lastIndexOf( '/' ) + 1 ) }</option>`
|
||||||
|
).join( '' );
|
||||||
|
|
||||||
|
if ( prev && this._rojos.some( r => r.path === prev ) )
|
||||||
|
{
|
||||||
|
picker.value = prev;
|
||||||
|
this._selectedPath = prev;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
this._selectedPath = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _createRojo(): Promise<void>
|
||||||
|
{
|
||||||
|
const projectId = Editor.get().projectId;
|
||||||
|
if ( !projectId ) return;
|
||||||
|
|
||||||
|
// Place new rojo in the same directory as the currently selected one,
|
||||||
|
// or directly in workspace/rojos if nothing is selected.
|
||||||
|
const dir = this._selectedPath
|
||||||
|
? parentDir( this._selectedPath )
|
||||||
|
: 'workspace/rojos';
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const res = await fetch( `/api/rojos/${ projectId }/create`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify( { parentDir: dir } ),
|
||||||
|
} );
|
||||||
|
|
||||||
|
if ( !res.ok ) return;
|
||||||
|
const { path: newPath } = await res.json() as { id: string; path: string; name: string };
|
||||||
|
|
||||||
|
await this._loadRojos();
|
||||||
|
this._selectedPath = newPath;
|
||||||
|
( this.querySelector( '.rcp-picker' ) as HTMLSelectElement ).value = newPath;
|
||||||
|
|
||||||
|
await Editor.get().openDocument( newPath );
|
||||||
|
}
|
||||||
|
catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
async _send(): Promise<void>
|
async _send(): Promise<void>
|
||||||
|
|
@ -89,13 +177,20 @@ class RojoChatPanel extends HTMLElement
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
const body: Record<string, string> = { id: this._conversationId, message: text };
|
||||||
|
|
||||||
|
if ( this._selectedPath )
|
||||||
|
{
|
||||||
|
body.projectId = Editor.get().projectId;
|
||||||
|
body.rojoPath = this._selectedPath;
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch( '/api/rojos/chat',
|
const response = await fetch( '/api/rojos/chat',
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify( { id: this._conversationId, message: text } ),
|
body: JSON.stringify( body ),
|
||||||
}
|
} );
|
||||||
);
|
|
||||||
|
|
||||||
if ( !response.body ) throw new Error( 'No response body' );
|
if ( !response.body ) throw new Error( 'No response body' );
|
||||||
|
|
||||||
|
|
@ -110,8 +205,6 @@ class RojoChatPanel extends HTMLElement
|
||||||
|
|
||||||
const raw = decoder.decode( value, { stream: true } );
|
const raw = decoder.decode( value, { stream: true } );
|
||||||
|
|
||||||
console.log( "Received raw:", raw );
|
|
||||||
|
|
||||||
for ( const line of raw.split( '\n' ) )
|
for ( const line of raw.split( '\n' ) )
|
||||||
{
|
{
|
||||||
const trimmed = line.trim();
|
const trimmed = line.trim();
|
||||||
|
|
@ -120,7 +213,6 @@ class RojoChatPanel extends HTMLElement
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
const msg = JSON.parse( trimmed ) as { type: string; text?: string };
|
const msg = JSON.parse( trimmed ) as { type: string; text?: string };
|
||||||
|
|
||||||
if ( msg.type === 'CHAT' && msg.text )
|
if ( msg.type === 'CHAT' && msg.text )
|
||||||
{
|
{
|
||||||
markdown += msg.text;
|
markdown += msg.text;
|
||||||
|
|
@ -128,32 +220,11 @@ class RojoChatPanel extends HTMLElement
|
||||||
history.scrollTop = history.scrollHeight;
|
history.scrollTop = history.scrollHeight;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch
|
|
||||||
{
|
|
||||||
// partial or non-JSON chunk — skip
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const remaining = decoder.decode();
|
|
||||||
if ( remaining.trim() )
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
const msg = JSON.parse( remaining.trim() ) as { type: string; text?: string };
|
|
||||||
if ( msg.type === 'CHAT' && msg.text )
|
|
||||||
{
|
|
||||||
markdown += msg.text;
|
|
||||||
assistantBubble.innerHTML = this._md!.render( markdown );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch {}
|
catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ( !markdown )
|
|
||||||
{
|
|
||||||
assistantBubble.textContent = '(no response)';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ( !markdown ) assistantBubble.textContent = '(no response)';
|
||||||
}
|
}
|
||||||
catch ( err )
|
catch ( err )
|
||||||
{
|
{
|
||||||
|
|
@ -168,7 +239,8 @@ class RojoChatPanel extends HTMLElement
|
||||||
|
|
||||||
addContextMenuEntries( dir: ContextMenuDirectory ): void
|
addContextMenuEntries( dir: ContextMenuDirectory ): void
|
||||||
{
|
{
|
||||||
dir.add( new ContextMenuReadOnlyEntry( dir, `Rojo Chat — ${this._conversationId.slice( 0, 8 )}` ) );
|
const rojo = this._rojos.find( r => r.path === this._selectedPath );
|
||||||
|
dir.add( new ContextMenuReadOnlyEntry( dir, `Rojo: ${ rojo?.name ?? 'none' }` ) );
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,294 @@
|
||||||
|
rojo-settings-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
background: #0f1117;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Toolbar ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.rsp-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: #13151f;
|
||||||
|
border-bottom: 1px solid #2a2d3a;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsp-toolbar-title {
|
||||||
|
flex: 1;
|
||||||
|
color: #e2e4ed;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsp-save {
|
||||||
|
padding: 4px 14px;
|
||||||
|
background: #1a5fd4;
|
||||||
|
border: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsp-save:hover:not(:disabled) { background: #1450b0; }
|
||||||
|
|
||||||
|
.rsp-save:disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Empty state ─────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.rsp-empty {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #555;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Portrait ────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.rsp-portrait-wrap {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 8px 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsp-portrait {
|
||||||
|
width: 300px;
|
||||||
|
height: 300px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #0a0c12;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsp-portrait svg {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Form ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.rsp-form {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsp-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsp-section-title {
|
||||||
|
color: #7c8cff;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
border-bottom: 1px solid #1e2235;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsp-name-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsp-name-label {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsp-sync-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: #1e2235;
|
||||||
|
border: 1px solid #2a2d3a;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #8890a8;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
margin-bottom: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsp-sync-btn:hover { background: #252a40; color: #c0c6de; }
|
||||||
|
|
||||||
|
.rsp-id-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 2px 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsp-id-label {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.07em;
|
||||||
|
color: #555;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsp-id-value {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-family: ui-monospace, "Cascadia Code", monospace;
|
||||||
|
color: #555;
|
||||||
|
word-break: break-all;
|
||||||
|
user-select: all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsp-label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
color: #8890a8;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsp-input {
|
||||||
|
background: #13151f;
|
||||||
|
color: #dde0f0;
|
||||||
|
border: 1px solid #2a2d3a;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsp-input:focus {
|
||||||
|
border-color: #3a4a8a;
|
||||||
|
background: #141620;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsp-textarea {
|
||||||
|
resize: vertical;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsp-system-prompt {
|
||||||
|
min-height: 120px;
|
||||||
|
font-family: ui-monospace, "Cascadia Code", monospace;
|
||||||
|
font-size: 0.83rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
select.rsp-input {
|
||||||
|
appearance: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsp-endpoint-external,
|
||||||
|
.rsp-endpoint-tunnel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tunnel picker ───────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.rsp-tunnel-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsp-tunnel-id-label { flex: 1; min-width: 0; }
|
||||||
|
|
||||||
|
.rsp-browse-tunnels-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: #1e2235;
|
||||||
|
border: 1px solid #2a2d3a;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #8890a8;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
margin-bottom: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsp-browse-tunnels-btn:hover { background: #252a40; color: #c0c6de; }
|
||||||
|
|
||||||
|
.rsp-tunnel-picker {
|
||||||
|
border: 1px solid #1e2235;
|
||||||
|
border-radius: 7px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #0d0f18;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsp-tunnel-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-height: 180px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsp-tunnel-entry {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 1px solid #1a1d2a;
|
||||||
|
transition: background 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsp-tunnel-entry:last-child { border-bottom: none; }
|
||||||
|
.rsp-tunnel-entry:hover { background: #141826; }
|
||||||
|
|
||||||
|
.rsp-tunnel-dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: #2a2d3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsp-tunnel-active .rsp-tunnel-dot {
|
||||||
|
background: #22c55e;
|
||||||
|
box-shadow: 0 0 5px #22c55e88;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsp-tunnel-name {
|
||||||
|
flex: 1;
|
||||||
|
color: #c0c6de;
|
||||||
|
font-size: 0.83rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsp-tunnel-purpose {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: #555;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsp-tunnel-loading,
|
||||||
|
.rsp-tunnel-empty {
|
||||||
|
padding: 10px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #555;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,424 @@
|
||||||
|
import { Editor } from '../../editor/Editor.js';
|
||||||
|
import { ContextMenuDirectory, ContextMenuReadOnlyEntry } from '../context-menu/context-menu.js';
|
||||||
|
|
||||||
|
const NS_INKSCAPE = 'http://www.inkscape.org/namespaces/inkscape';
|
||||||
|
|
||||||
|
function inkscapeLabel( el: Element ): string
|
||||||
|
{
|
||||||
|
return el.getAttributeNS( NS_INKSCAPE, 'label' ) ?? el.getAttribute( 'inkscape:label' ) ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface RojoEndpoint
|
||||||
|
{
|
||||||
|
type: 'external' | 'tunnel';
|
||||||
|
url: string;
|
||||||
|
model: string;
|
||||||
|
apiKey: string;
|
||||||
|
tunnelId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RojoColor
|
||||||
|
{
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
target: 'fill' | 'stroke' | 'both';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RojoLayer
|
||||||
|
{
|
||||||
|
name: string;
|
||||||
|
selected: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RojoAppearance
|
||||||
|
{
|
||||||
|
colors: RojoColor[];
|
||||||
|
layers: RojoLayer[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RojoSettings
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
systemPrompt: string;
|
||||||
|
endpoint: RojoEndpoint;
|
||||||
|
appearance: RojoAppearance;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultSettings(): RojoSettings
|
||||||
|
{
|
||||||
|
return {
|
||||||
|
id: '',
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
systemPrompt: '',
|
||||||
|
endpoint: { type: 'external', url: '', model: '', apiKey: '', tunnelId: '' },
|
||||||
|
appearance: { colors: [], layers: [] },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Name normalization ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function normalizeName( name: string ): string
|
||||||
|
{
|
||||||
|
return name
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize( 'NFD' )
|
||||||
|
.replace( /[̀-ͯ]/g, '' )
|
||||||
|
.replace( /[^a-z0-9\s-]/g, '' )
|
||||||
|
.trim()
|
||||||
|
.replace( /\s+/g, '-' )
|
||||||
|
.replace( /-+/g, '-' );
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SVG processing ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const CHAT_MASK_VIEWBOX = '1.5883274 0.60429424 25.973324 23.865683';
|
||||||
|
|
||||||
|
async function loadPortraitSvg(): Promise<string>
|
||||||
|
{
|
||||||
|
const res = await fetch( '/rojos/rojo-base.svg' );
|
||||||
|
const text = await res.text();
|
||||||
|
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString( text, 'image/svg+xml' );
|
||||||
|
const svg = doc.documentElement;
|
||||||
|
|
||||||
|
doc.querySelector( 'sodipodi\\:namedview, [id="namedview7"]' )?.remove();
|
||||||
|
|
||||||
|
const toRemove: Element[] = [];
|
||||||
|
svg.querySelectorAll( '*' ).forEach( el =>
|
||||||
|
{
|
||||||
|
const label = inkscapeLabel( el );
|
||||||
|
if ( label.endsWith( '-pivot' ) || label === 'chat-cutout' || label === 'chat-mask' )
|
||||||
|
{
|
||||||
|
toRemove.push( el );
|
||||||
|
}
|
||||||
|
} );
|
||||||
|
toRemove.forEach( el => el.remove() );
|
||||||
|
|
||||||
|
svg.setAttribute( 'viewBox', CHAT_MASK_VIEWBOX );
|
||||||
|
svg.setAttribute( 'width', '100%' );
|
||||||
|
svg.setAttribute( 'height', '100%' );
|
||||||
|
|
||||||
|
return new XMLSerializer().serializeToString( svg );
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class RojoSettingsPanel extends HTMLElement
|
||||||
|
{
|
||||||
|
currentPath: string | null = null;
|
||||||
|
_initialized = false;
|
||||||
|
_ignoreChange = false;
|
||||||
|
_appearance: RojoAppearance = { colors: [], layers: [] };
|
||||||
|
_rojoId = '';
|
||||||
|
|
||||||
|
connectedCallback(): void
|
||||||
|
{
|
||||||
|
if ( this._initialized ) return;
|
||||||
|
this._initialized = true;
|
||||||
|
|
||||||
|
this.className = 'rojo-settings-panel';
|
||||||
|
this.innerHTML = `
|
||||||
|
<div class="rsp-toolbar">
|
||||||
|
<span class="rsp-toolbar-title">🤖 Rojo Settings</span>
|
||||||
|
<button class="rsp-save" disabled>Save</button>
|
||||||
|
</div>
|
||||||
|
<div class="rsp-empty">Open a .rojo file from the file tree</div>
|
||||||
|
<div class="rsp-form" style="display:none">
|
||||||
|
<div class="rsp-portrait-wrap">
|
||||||
|
<div class="rsp-portrait"></div>
|
||||||
|
</div>
|
||||||
|
<div class="rsp-section">
|
||||||
|
<div class="rsp-name-row">
|
||||||
|
<label class="rsp-label rsp-name-label">Name
|
||||||
|
<input class="rsp-input" data-field="name" type="text" placeholder="My Rojo">
|
||||||
|
</label>
|
||||||
|
<button class="rsp-sync-btn" title="Rename file to match this name">Sync filename</button>
|
||||||
|
</div>
|
||||||
|
<div class="rsp-id-row">
|
||||||
|
<span class="rsp-id-label">ID</span>
|
||||||
|
<span class="rsp-id-value"></span>
|
||||||
|
</div>
|
||||||
|
<label class="rsp-label">Description
|
||||||
|
<textarea class="rsp-input rsp-textarea" data-field="description" placeholder="What this rojo does…" rows="2"></textarea>
|
||||||
|
</label>
|
||||||
|
<label class="rsp-label">System Prompt
|
||||||
|
<textarea class="rsp-input rsp-textarea rsp-system-prompt" data-field="systemPrompt" placeholder="You are a helpful assistant…" rows="6"></textarea>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="rsp-section">
|
||||||
|
<div class="rsp-section-title">Endpoint</div>
|
||||||
|
<label class="rsp-label">Type
|
||||||
|
<select class="rsp-input" data-field="endpoint.type">
|
||||||
|
<option value="external">External / Local URL</option>
|
||||||
|
<option value="tunnel">Tunnel (tunnel.rokojori.com)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div class="rsp-endpoint-external">
|
||||||
|
<label class="rsp-label">Base URL
|
||||||
|
<input class="rsp-input" data-field="endpoint.url" type="text" placeholder="http://localhost:11434/v1">
|
||||||
|
</label>
|
||||||
|
<label class="rsp-label">API Key
|
||||||
|
<input class="rsp-input" data-field="endpoint.apiKey" type="password" placeholder="sk-… (or leave blank for local)">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="rsp-endpoint-tunnel" style="display:none">
|
||||||
|
<div class="rsp-tunnel-row">
|
||||||
|
<label class="rsp-label rsp-tunnel-id-label">Tunnel ID
|
||||||
|
<input class="rsp-input" data-field="endpoint.tunnelId" type="text" placeholder="uuid of the tunnel">
|
||||||
|
</label>
|
||||||
|
<button class="rsp-browse-tunnels-btn" title="Browse available tunnels">Browse</button>
|
||||||
|
</div>
|
||||||
|
<div class="rsp-tunnel-picker" style="display:none">
|
||||||
|
<div class="rsp-tunnel-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="rsp-label">Model
|
||||||
|
<input class="rsp-input" data-field="endpoint.model" type="text" placeholder="gpt-4o / llama3 / gemma3 / …">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
this.querySelector( '.rsp-save' )!.addEventListener( 'click', () => this._save() );
|
||||||
|
this.querySelector( '.rsp-sync-btn' )!.addEventListener( 'click', () => this._syncFilename() );
|
||||||
|
this.querySelector( '.rsp-browse-tunnels-btn' )!.addEventListener( 'click', () => this._browseTunnels() );
|
||||||
|
|
||||||
|
this.querySelectorAll( '[data-field]' ).forEach( el =>
|
||||||
|
{
|
||||||
|
el.addEventListener( 'input', () => this._onFieldChange() );
|
||||||
|
} );
|
||||||
|
|
||||||
|
( this.querySelector( '[data-field="endpoint.type"]' ) as HTMLSelectElement )
|
||||||
|
.addEventListener( 'change', () => this._updateEndpointVisibility() );
|
||||||
|
|
||||||
|
Editor.get().onDocumentOpened.addListener( e =>
|
||||||
|
{
|
||||||
|
if ( 'rojo-settings-panel' !== e.editorTag ) return;
|
||||||
|
this._loadDocument( e.path, e.content );
|
||||||
|
} );
|
||||||
|
|
||||||
|
document.addEventListener( 'keydown', ( e: KeyboardEvent ) =>
|
||||||
|
{
|
||||||
|
if ( !this.currentPath ) return;
|
||||||
|
if ( e.ctrlKey && e.key === 's' ) { e.preventDefault(); this._save(); }
|
||||||
|
} );
|
||||||
|
|
||||||
|
this._initPortrait();
|
||||||
|
}
|
||||||
|
|
||||||
|
async _browseTunnels(): Promise<void>
|
||||||
|
{
|
||||||
|
const picker = this.querySelector( '.rsp-tunnel-picker' ) as HTMLElement;
|
||||||
|
const list = this.querySelector( '.rsp-tunnel-list' ) as HTMLElement;
|
||||||
|
|
||||||
|
if ( picker.style.display !== 'none' ) { picker.style.display = 'none'; return; }
|
||||||
|
|
||||||
|
list.innerHTML = '<div class="rsp-tunnel-loading">Loading…</div>';
|
||||||
|
picker.style.display = '';
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const res = await fetch( '/api/rojos/tunnels/browse' );
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if ( !res.ok || !Array.isArray( data ) )
|
||||||
|
{
|
||||||
|
list.innerHTML = `<div class="rsp-tunnel-empty">Error: ${ data?.error ?? 'Unexpected response' }</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tunnels = data as Array<{ id: string; name: string; purpose: string; active: boolean; }>;
|
||||||
|
|
||||||
|
if ( !tunnels.length )
|
||||||
|
{
|
||||||
|
list.innerHTML = '<div class="rsp-tunnel-empty">No tunnels available. Add one in the Tunnel Agent app.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.innerHTML = tunnels.map( t => `
|
||||||
|
<div class="rsp-tunnel-entry ${ t.active ? 'rsp-tunnel-active' : '' }" data-tunnel-id="${ t.id }">
|
||||||
|
<span class="rsp-tunnel-dot"></span>
|
||||||
|
<span class="rsp-tunnel-name">${ t.name }</span>
|
||||||
|
<span class="rsp-tunnel-purpose">${ t.purpose }</span>
|
||||||
|
</div>
|
||||||
|
` ).join( '' );
|
||||||
|
|
||||||
|
list.querySelectorAll( '.rsp-tunnel-entry' ).forEach( entry =>
|
||||||
|
{
|
||||||
|
entry.addEventListener( 'click', () =>
|
||||||
|
{
|
||||||
|
const id = ( entry as HTMLElement ).dataset.tunnelId ?? '';
|
||||||
|
this._setField( 'endpoint.tunnelId', id );
|
||||||
|
this._onFieldChange();
|
||||||
|
picker.style.display = 'none';
|
||||||
|
} );
|
||||||
|
} );
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
list.innerHTML = '<div class="rsp-tunnel-empty">Could not reach tunnel server.</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _initPortrait(): Promise<void>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const svgHtml = await loadPortraitSvg();
|
||||||
|
( this.querySelector( '.rsp-portrait' ) as HTMLElement ).innerHTML = svgHtml;
|
||||||
|
}
|
||||||
|
catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
_loadDocument( path: string, content: string ): void
|
||||||
|
{
|
||||||
|
this.currentPath = path;
|
||||||
|
this._updateTabLabel( path );
|
||||||
|
|
||||||
|
let settings: RojoSettings = defaultSettings();
|
||||||
|
try { settings = JSON.parse( content ); }
|
||||||
|
catch {}
|
||||||
|
|
||||||
|
this._rojoId = settings.id ?? '';
|
||||||
|
this._appearance = settings.appearance ?? { colors: [], layers: [] };
|
||||||
|
|
||||||
|
this._ignoreChange = true;
|
||||||
|
this._populateForm( settings );
|
||||||
|
this._ignoreChange = false;
|
||||||
|
|
||||||
|
( this.querySelector( '.rsp-empty' ) as HTMLElement ).style.display = 'none';
|
||||||
|
( this.querySelector( '.rsp-form' ) as HTMLElement ).style.display = '';
|
||||||
|
this._updateSaveButton( false );
|
||||||
|
}
|
||||||
|
|
||||||
|
_populateForm( s: RojoSettings ): void
|
||||||
|
{
|
||||||
|
this._setField( 'name', s.name ?? '' );
|
||||||
|
this._setField( 'description', s.description ?? '' );
|
||||||
|
this._setField( 'systemPrompt', s.systemPrompt ?? '' );
|
||||||
|
this._setField( 'endpoint.type', s.endpoint?.type ?? 'external' );
|
||||||
|
this._setField( 'endpoint.url', s.endpoint?.url ?? '' );
|
||||||
|
this._setField( 'endpoint.apiKey', s.endpoint?.apiKey ?? '' );
|
||||||
|
this._setField( 'endpoint.tunnelId', s.endpoint?.tunnelId ?? '' );
|
||||||
|
this._setField( 'endpoint.model', s.endpoint?.model ?? '' );
|
||||||
|
|
||||||
|
( this.querySelector( '.rsp-id-value' ) as HTMLElement ).textContent =
|
||||||
|
s.id ? s.id : '—';
|
||||||
|
|
||||||
|
this._updateEndpointVisibility();
|
||||||
|
}
|
||||||
|
|
||||||
|
_setField( field: string, value: string ): void
|
||||||
|
{
|
||||||
|
const el = this.querySelector( `[data-field="${ field }"]` ) as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
|
||||||
|
if ( el ) el.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
_getField( field: string ): string
|
||||||
|
{
|
||||||
|
const el = this.querySelector( `[data-field="${ field }"]` ) as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
|
||||||
|
return el ? el.value : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
_readSettings(): RojoSettings
|
||||||
|
{
|
||||||
|
return {
|
||||||
|
id: this._rojoId,
|
||||||
|
name: this._getField( 'name' ),
|
||||||
|
description: this._getField( 'description' ),
|
||||||
|
systemPrompt: this._getField( 'systemPrompt' ),
|
||||||
|
endpoint: {
|
||||||
|
type: this._getField( 'endpoint.type' ) as 'external' | 'tunnel',
|
||||||
|
url: this._getField( 'endpoint.url' ),
|
||||||
|
apiKey: this._getField( 'endpoint.apiKey' ),
|
||||||
|
tunnelId: this._getField( 'endpoint.tunnelId' ),
|
||||||
|
model: this._getField( 'endpoint.model' ),
|
||||||
|
},
|
||||||
|
appearance: this._appearance,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
_onFieldChange(): void
|
||||||
|
{
|
||||||
|
if ( this._ignoreChange || !this.currentPath ) return;
|
||||||
|
Editor.get().markDirty( this.currentPath, JSON.stringify( this._readSettings(), null, 2 ) );
|
||||||
|
this._updateSaveButton( true );
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateEndpointVisibility(): void
|
||||||
|
{
|
||||||
|
const type = this._getField( 'endpoint.type' );
|
||||||
|
( this.querySelector( '.rsp-endpoint-external' ) as HTMLElement ).style.display = type === 'external' ? '' : 'none';
|
||||||
|
( this.querySelector( '.rsp-endpoint-tunnel' ) as HTMLElement ).style.display = type === 'tunnel' ? '' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
async _syncFilename(): Promise<void>
|
||||||
|
{
|
||||||
|
if ( !this.currentPath ) return;
|
||||||
|
|
||||||
|
const name = this._getField( 'name' );
|
||||||
|
const slug = normalizeName( name );
|
||||||
|
if ( !slug ) return;
|
||||||
|
|
||||||
|
const newName = slug + '.rojo';
|
||||||
|
const oldPath = this.currentPath;
|
||||||
|
|
||||||
|
const res = await fetch( `/api/files/${ Editor.get().projectId }/rename`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify( { path: oldPath, newName } ),
|
||||||
|
} );
|
||||||
|
|
||||||
|
if ( !res.ok ) return;
|
||||||
|
|
||||||
|
// Derive new path: same directory, new filename
|
||||||
|
const dir = oldPath.lastIndexOf( '/' ) >= 0
|
||||||
|
? oldPath.slice( 0, oldPath.lastIndexOf( '/' ) )
|
||||||
|
: '';
|
||||||
|
const newPath = dir ? `${ dir }/${ newName }` : newName;
|
||||||
|
|
||||||
|
// Migrate openDocs entry to the new path
|
||||||
|
const editor = Editor.get();
|
||||||
|
const doc = editor.openDocs.get( oldPath );
|
||||||
|
if ( doc )
|
||||||
|
{
|
||||||
|
editor.openDocs.set( newPath, doc );
|
||||||
|
editor.openDocs.delete( oldPath );
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentPath = newPath;
|
||||||
|
this._updateTabLabel( newPath );
|
||||||
|
}
|
||||||
|
|
||||||
|
async _save(): Promise<void>
|
||||||
|
{
|
||||||
|
if ( !this.currentPath ) return;
|
||||||
|
await Editor.get().save( this.currentPath );
|
||||||
|
this._updateSaveButton( false );
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateTabLabel( path: string ): void
|
||||||
|
{
|
||||||
|
const name = path ? path.slice( path.lastIndexOf( '/' ) + 1 ) : '';
|
||||||
|
this.dispatchEvent( new CustomEvent( 'panel:label-change', { bubbles: true, detail: { label: '🤖 ' + name } } ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateSaveButton( dirty: boolean ): void
|
||||||
|
{
|
||||||
|
( this.querySelector( '.rsp-save' ) as HTMLButtonElement ).disabled = !dirty;
|
||||||
|
}
|
||||||
|
|
||||||
|
addContextMenuEntries( dir: ContextMenuDirectory ): void
|
||||||
|
{
|
||||||
|
dir.add( new ContextMenuReadOnlyEntry( dir, this.currentPath ? `Rojo: ${ this.currentPath }` : 'No rojo open' ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define( 'rojo-settings-panel', RojoSettingsPanel );
|
||||||
|
|
@ -78,6 +78,7 @@ class TabContainer extends HTMLElement {
|
||||||
{ label: 'Code Editor', panelType: 'code-panel', tag: 'code-panel' },
|
{ label: 'Code Editor', panelType: 'code-panel', tag: 'code-panel' },
|
||||||
{ label: 'File Tree', panelType: 'file-tree', tag: 'file-tree-panel' },
|
{ label: 'File Tree', panelType: 'file-tree', tag: 'file-tree-panel' },
|
||||||
{ label: 'Rojo Chat', panelType: 'rojo-chat', tag: 'rojo-chat-panel' },
|
{ label: 'Rojo Chat', panelType: 'rojo-chat', tag: 'rojo-chat-panel' },
|
||||||
|
{ label: 'Rojo Settings', panelType: 'rojo-settings', tag: 'rojo-settings-panel' },
|
||||||
];
|
];
|
||||||
for (const pt of panelTypes) {
|
for (const pt of panelTypes) {
|
||||||
addDir.add(new ContextMenuEntry(addDir, pt.label, () => {
|
addDir.add(new ContextMenuEntry(addDir, pt.label, () => {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ export class FileEditorRegistry
|
||||||
{
|
{
|
||||||
static readonly DefaultEntries: RegistryEntry[] =
|
static readonly DefaultEntries: RegistryEntry[] =
|
||||||
[
|
[
|
||||||
|
{ suffix: 'rojo', editor: 'RojoSettingsPanel' },
|
||||||
{ suffix: 'html', editor: 'HTMLEditorPanel' },
|
{ suffix: 'html', editor: 'HTMLEditorPanel' },
|
||||||
{ suffix: 'htm', editor: 'HTMLEditorPanel' },
|
{ suffix: 'htm', editor: 'HTMLEditorPanel' },
|
||||||
{ suffix: 'js', editor: 'CodePanel' },
|
{ suffix: 'js', editor: 'CodePanel' },
|
||||||
|
|
@ -26,6 +27,7 @@ export class FileEditorRegistry
|
||||||
|
|
||||||
static readonly EditorTagNames: Record<string, string> =
|
static readonly EditorTagNames: Record<string, string> =
|
||||||
{
|
{
|
||||||
|
'RojoSettingsPanel': 'rojo-settings-panel',
|
||||||
'HTMLEditorPanel': 'html-editor-panel',
|
'HTMLEditorPanel': 'html-editor-panel',
|
||||||
'CodePanel': 'code-panel',
|
'CodePanel': 'code-panel',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
<link rel="stylesheet" href="/components/html-editor-panel/html-editor-panel.css">
|
<link rel="stylesheet" href="/components/html-editor-panel/html-editor-panel.css">
|
||||||
<link rel="stylesheet" href="/components/confirm-dialog/confirm-dialog.css">
|
<link rel="stylesheet" href="/components/confirm-dialog/confirm-dialog.css">
|
||||||
<link rel="stylesheet" href="/components/rojo-chat-panel/rojo-chat-panel.css">
|
<link rel="stylesheet" href="/components/rojo-chat-panel/rojo-chat-panel.css">
|
||||||
|
<link rel="stylesheet" href="/components/rojo-settings-panel/rojo-settings-panel.css">
|
||||||
<link rel="stylesheet" href="/vendor/codemirror.min.css">
|
<link rel="stylesheet" href="/vendor/codemirror.min.css">
|
||||||
<link rel="stylesheet" href="/components/code-panel/code-panel.css">
|
<link rel="stylesheet" href="/components/code-panel/code-panel.css">
|
||||||
<style>
|
<style>
|
||||||
|
|
@ -28,6 +29,7 @@
|
||||||
<script type="module" src="/components/confirm-dialog/confirm-dialog.js"></script>
|
<script type="module" src="/components/confirm-dialog/confirm-dialog.js"></script>
|
||||||
<script src="/vendor/markdown-it.min.js"></script>
|
<script src="/vendor/markdown-it.min.js"></script>
|
||||||
<script type="module" src="/components/rojo-chat-panel/rojo-chat-panel.js"></script>
|
<script type="module" src="/components/rojo-chat-panel/rojo-chat-panel.js"></script>
|
||||||
|
<script type="module" src="/components/rojo-settings-panel/rojo-settings-panel.js"></script>
|
||||||
<script src="/vendor/codemirror.min.js"></script>
|
<script src="/vendor/codemirror.min.js"></script>
|
||||||
<script src="/vendor/cm-mode-xml.min.js"></script>
|
<script src="/vendor/cm-mode-xml.min.js"></script>
|
||||||
<script src="/vendor/cm-mode-javascript.min.js"></script>
|
<script src="/vendor/cm-mode-javascript.min.js"></script>
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,17 @@
|
||||||
import { ChatOpenAI } from "@langchain/openai";
|
import { ChatOpenAI } from "@langchain/openai";
|
||||||
import { HumanMessage, AIMessage, BaseMessage } from "@langchain/core/messages";
|
import { HumanMessage, AIMessage, SystemMessage, BaseMessage } from "@langchain/core/messages";
|
||||||
import { RojosConfig } from "./RojosConfig";
|
|
||||||
|
|
||||||
const model = new ChatOpenAI({
|
export interface AgentConfig
|
||||||
model: RojosConfig.model,
|
{
|
||||||
apiKey: RojosConfig.apiKey,
|
baseURL: string;
|
||||||
configuration: {
|
model: string;
|
||||||
baseURL: RojosConfig.baseURL,
|
apiKey: string;
|
||||||
},
|
headers?: Record<string, string>;
|
||||||
});
|
systemPrompt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
type MessageData = { role: "user" | "assistant"; content: string };
|
type MessageRole = "user" | "assistant" | "system";
|
||||||
|
type MessageData = { role: MessageRole; content: string };
|
||||||
type SessionData = { id: string; messages: MessageData[] };
|
type SessionData = { id: string; messages: MessageData[] };
|
||||||
|
|
||||||
const sessions = new Map<string, SessionData>();
|
const sessions = new Map<string, SessionData>();
|
||||||
|
|
@ -18,15 +19,36 @@ const sessions = new Map<string, SessionData>();
|
||||||
function toBaseMessages( messages: MessageData[] ): BaseMessage[]
|
function toBaseMessages( messages: MessageData[] ): BaseMessage[]
|
||||||
{
|
{
|
||||||
return messages.map( m =>
|
return messages.map( m =>
|
||||||
m.role === "user" ? new HumanMessage( m.content ) : new AIMessage( m.content )
|
{
|
||||||
);
|
if ( m.role === "user" ) return new HumanMessage( m.content );
|
||||||
|
if ( m.role === "assistant" ) return new AIMessage( m.content );
|
||||||
|
return new SystemMessage( m.content );
|
||||||
|
} );
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAgentStream( id: string, userMessage: string )
|
export async function getAgentStream( id: string, userMessage: string, config: AgentConfig )
|
||||||
{
|
{
|
||||||
|
const model = new ChatOpenAI(
|
||||||
|
{
|
||||||
|
model: config.model,
|
||||||
|
apiKey: config.apiKey,
|
||||||
|
configuration:
|
||||||
|
{
|
||||||
|
baseURL: config.baseURL,
|
||||||
|
defaultHeaders: config.headers ?? {},
|
||||||
|
},
|
||||||
|
} );
|
||||||
|
|
||||||
if ( !sessions.has( id ) )
|
if ( !sessions.has( id ) )
|
||||||
{
|
{
|
||||||
sessions.set( id, { id, messages: [] } );
|
const messages: MessageData[] = [];
|
||||||
|
|
||||||
|
if ( config.systemPrompt?.trim() )
|
||||||
|
{
|
||||||
|
messages.push( { role: "system", content: config.systemPrompt } );
|
||||||
|
}
|
||||||
|
|
||||||
|
sessions.set( id, { id, messages } );
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = sessions.get( id )!;
|
const session = sessions.get( id )!;
|
||||||
|
|
@ -35,7 +57,7 @@ export async function getAgentStream( id: string, userMessage: string )
|
||||||
return model.stream( toBaseMessages( session.messages ) );
|
return model.stream( toBaseMessages( session.messages ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateAgentConversation( id: string, assistantMessage: string )
|
export function updateAgentConversation( id: string, assistantMessage: string ): void
|
||||||
{
|
{
|
||||||
const session = sessions.get( id );
|
const session = sessions.get( id );
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,202 @@
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import crypto from "crypto";
|
||||||
import { requireAuth } from "../middleware/auth";
|
import { requireAuth } from "../middleware/auth";
|
||||||
import { getAgentStream, updateAgentConversation } from "../rojos/RojosAgent";
|
import { getAgentStream, updateAgentConversation, AgentConfig } from "../rojos/RojosAgent";
|
||||||
import { RJLog } from "../../library-ts/node/log/RJLog";
|
import { readProjectFile, writeProjectFile, createProjectDirectory } from "../storage";
|
||||||
|
import { checkAccess } from "../projectAccess";
|
||||||
|
import { RojosConfig } from "../rojos/RojosConfig";
|
||||||
|
import { ROOT } from "../rootDir";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
const STORAGE = path.join( ROOT, "build", "data", "storage" );
|
||||||
|
|
||||||
router.use( requireAuth );
|
router.use( requireAuth );
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function normalizeUrl( url: string ): string
|
||||||
|
{
|
||||||
|
if ( !url.startsWith( "http://" ) && !url.startsWith( "https://" ) )
|
||||||
|
{
|
||||||
|
return "http://" + url;
|
||||||
|
}
|
||||||
|
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pick<T>( arr: T[] ): T
|
||||||
|
{
|
||||||
|
return arr[ Math.floor( Math.random() * arr.length ) ];
|
||||||
|
}
|
||||||
|
|
||||||
|
function cap( s: string ): string { return s[ 0 ].toUpperCase() + s.slice( 1 ); }
|
||||||
|
|
||||||
|
const ADJECTIVES = [
|
||||||
|
"funky", "cool", "groovy", "wild", "sharp", "slick", "swift", "bold",
|
||||||
|
"bright", "calm", "daring", "eager", "fierce", "jolly", "lively",
|
||||||
|
"quirky", "smart", "vivid", "zesty", "sleek", "crisp", "nifty", "rad",
|
||||||
|
"sunny", "sassy", "snappy", "peppy", "zippy", "breezy", "mellow",
|
||||||
|
];
|
||||||
|
|
||||||
|
const ROLES = [
|
||||||
|
"boss", "doctor", "player", "teacher", "hunter", "builder", "maker",
|
||||||
|
"dancer", "singer", "painter", "writer", "coder", "pilot", "chef",
|
||||||
|
"sailor", "rider", "climber", "dreamer", "wanderer", "scout", "keeper",
|
||||||
|
"helper", "guide", "mentor", "runner", "seeker", "ranger", "scholar",
|
||||||
|
];
|
||||||
|
|
||||||
|
// 50% European, 50% rest of world — modern names, male/female mixed
|
||||||
|
const NAMES = [
|
||||||
|
"Sofia", "Elena", "Lucas", "Mia", "Noah", "Emma", "Leon", "Lena",
|
||||||
|
"Felix", "Anna", "Max", "Clara", "Julian", "Sara", "Lars", "Nina",
|
||||||
|
"Tom", "Lara", "Erik", "Ida", "Hugo", "Vera", "Otto", "Maja",
|
||||||
|
"Kai", "Maya", "Arjun", "Zara", "Aisha", "Omar", "Nala", "Ravi",
|
||||||
|
"Yuki", "Kenji", "Amara", "Diego", "Mateo", "Jae", "Sora", "Kira",
|
||||||
|
"Rio", "Zion", "Nova", "Leila", "Cyrus", "Noa", "Bao", "Mila",
|
||||||
|
];
|
||||||
|
|
||||||
|
function generateRojoName(): { display: string; slug: string }
|
||||||
|
{
|
||||||
|
const adj = pick( ADJECTIVES );
|
||||||
|
const role = pick( ROLES );
|
||||||
|
const name = pick( NAMES );
|
||||||
|
return {
|
||||||
|
display: `${ cap( adj ) } ${ cap( role ) } ${ name }`,
|
||||||
|
slug: `${ adj }-${ role }-${ name.toLowerCase() }`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Recursive *.rojo scanner ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function scanRojoFiles( dir: string, projectRoot: string, out: string[] = [] ): string[]
|
||||||
|
{
|
||||||
|
if ( !fs.existsSync( dir ) ) return out;
|
||||||
|
|
||||||
|
for ( const entry of fs.readdirSync( dir, { withFileTypes: true } ) )
|
||||||
|
{
|
||||||
|
const full = path.join( dir, entry.name );
|
||||||
|
|
||||||
|
if ( entry.isDirectory() )
|
||||||
|
{
|
||||||
|
scanRojoFiles( full, projectRoot, out );
|
||||||
|
}
|
||||||
|
else if ( entry.name.endsWith( ".rojo" ) )
|
||||||
|
{
|
||||||
|
out.push( path.relative( projectRoot, full ).replace( /\\/g, "/" ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tunnel browser (proxies to tunnel.rokojori.com) ─────────────────────────
|
||||||
|
|
||||||
|
router.get( "/tunnels/browse", async ( req, res ) =>
|
||||||
|
{
|
||||||
|
const purpose = typeof req.query.purpose === "string" ? req.query.purpose : "";
|
||||||
|
|
||||||
|
const token = req.cookies?.accessToken
|
||||||
|
|| ( typeof req.headers.authorization === "string" && req.headers.authorization.startsWith( "Bearer " )
|
||||||
|
? req.headers.authorization.slice( 7 )
|
||||||
|
: "" );
|
||||||
|
|
||||||
|
if ( !token ) { res.status( 401 ).json( { error: "No auth token" } ); return; }
|
||||||
|
|
||||||
|
const tunnelServer = process.env.TUNNEL_SERVER_URL || "https://tunnel.rokojori.com";
|
||||||
|
const url = purpose
|
||||||
|
? `${ tunnelServer }/api/tunnels/available?purpose=${ encodeURIComponent( purpose ) }`
|
||||||
|
: `${ tunnelServer }/api/tunnels/available`;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const upstream = await fetch( url, { headers: { Authorization: `Bearer ${ token }` } } );
|
||||||
|
const data = await upstream.json();
|
||||||
|
res.json( data );
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
res.status( 502 ).json( { error: "Could not reach tunnel server" } );
|
||||||
|
}
|
||||||
|
} );
|
||||||
|
|
||||||
|
// ── List rojos ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
router.get( "/:projectId/list", ( req, res ) =>
|
||||||
|
{
|
||||||
|
const denied = checkAccess( req.params.projectId, req.user!, "view" );
|
||||||
|
if ( denied ) { res.status( denied.status ).json( { error: denied.error } ); return; }
|
||||||
|
|
||||||
|
const projectRoot = path.join( STORAGE, req.params.projectId, "root" );
|
||||||
|
const rojoDir = path.join( projectRoot, "workspace", "rojos" );
|
||||||
|
const files = scanRojoFiles( rojoDir, projectRoot );
|
||||||
|
|
||||||
|
const rojos = files.map( filePath =>
|
||||||
|
{
|
||||||
|
let id = "", name = filePath, description = "";
|
||||||
|
|
||||||
|
const content = readProjectFile( req.params.projectId, filePath );
|
||||||
|
|
||||||
|
if ( content )
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const s = JSON.parse( content );
|
||||||
|
id = s.id || "";
|
||||||
|
name = s.name || name;
|
||||||
|
description = s.description || "";
|
||||||
|
}
|
||||||
|
catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { id, name, description, path: filePath };
|
||||||
|
} );
|
||||||
|
|
||||||
|
res.json( rojos );
|
||||||
|
} );
|
||||||
|
|
||||||
|
// ── Create rojo ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
router.post( "/:projectId/create", ( req, res ) =>
|
||||||
|
{
|
||||||
|
const denied = checkAccess( req.params.projectId, req.user!, "edit" );
|
||||||
|
if ( denied ) { res.status( denied.status ).json( { error: denied.error } ); return; }
|
||||||
|
|
||||||
|
const { parentDir } = req.body as { parentDir?: string };
|
||||||
|
const dir = parentDir || "workspace/rojos";
|
||||||
|
const { display, slug } = generateRojoName();
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const filePath = `${ dir }/${ slug }.rojo`;
|
||||||
|
|
||||||
|
const defaults = JSON.stringify(
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
name: display,
|
||||||
|
description: "",
|
||||||
|
systemPrompt: "",
|
||||||
|
endpoint: { type: "external", url: "", model: "", apiKey: "", tunnelId: "" },
|
||||||
|
appearance: { colors: [], layers: [] },
|
||||||
|
}, null, 2 );
|
||||||
|
|
||||||
|
const ok = writeProjectFile( req.params.projectId, filePath, defaults );
|
||||||
|
|
||||||
|
if ( !ok ) { res.status( 500 ).json( { error: "Could not create rojo file" } ); return; }
|
||||||
|
|
||||||
|
res.json( { id, path: filePath, name: display } );
|
||||||
|
} );
|
||||||
|
|
||||||
|
// ── Chat ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
router.post( "/chat", async ( req, res ) =>
|
router.post( "/chat", async ( req, res ) =>
|
||||||
{
|
{
|
||||||
const { id, message } = req.body as { id: string; message: string };
|
const { id, message, projectId, rojoPath } = req.body as
|
||||||
|
{
|
||||||
// RJLog.log( "Chat", { id, message } );
|
id: string;
|
||||||
|
message: string;
|
||||||
|
projectId?: string;
|
||||||
|
rojoPath?: string;
|
||||||
|
};
|
||||||
|
|
||||||
if ( !id || !message )
|
if ( !id || !message )
|
||||||
{
|
{
|
||||||
|
|
@ -18,9 +204,53 @@ router.post( "/chat", async ( req, res ) =>
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const config: AgentConfig = {
|
||||||
|
baseURL: RojosConfig.baseURL,
|
||||||
|
model: RojosConfig.model,
|
||||||
|
apiKey: RojosConfig.apiKey,
|
||||||
|
systemPrompt: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
if ( projectId && rojoPath )
|
||||||
|
{
|
||||||
|
const content = readProjectFile( projectId, rojoPath );
|
||||||
|
|
||||||
|
if ( content )
|
||||||
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
const agentStream = await getAgentStream( id, message );
|
const settings = JSON.parse( content );
|
||||||
|
const ep = settings.endpoint ?? {};
|
||||||
|
|
||||||
|
config.systemPrompt = settings.systemPrompt ?? "";
|
||||||
|
|
||||||
|
if ( ep.type === "tunnel" && ep.tunnelId )
|
||||||
|
{
|
||||||
|
const tunnelServer = process.env.TUNNEL_SERVER_URL || "https://tunnel.rokojori.com";
|
||||||
|
config.baseURL = `${ tunnelServer }/t/${ ep.tunnelId }/v1`;
|
||||||
|
config.model = ep.model || RojosConfig.model;
|
||||||
|
config.apiKey = "not-needed";
|
||||||
|
|
||||||
|
const token = req.cookies?.accessToken
|
||||||
|
|| ( typeof req.headers.authorization === "string" && req.headers.authorization.startsWith( "Bearer " )
|
||||||
|
? req.headers.authorization.slice( 7 )
|
||||||
|
: "" );
|
||||||
|
if ( token ) config.headers = { Authorization: `Bearer ${ token }` };
|
||||||
|
}
|
||||||
|
else if ( ep.url )
|
||||||
|
{
|
||||||
|
config.baseURL = normalizeUrl( ep.url );
|
||||||
|
config.model = ep.model || RojosConfig.model;
|
||||||
|
config.apiKey = ep.apiKey || "not-needed";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const agentStream = await getAgentStream( id, message, config );
|
||||||
|
|
||||||
res.setHeader( "Content-Type", "text/event-stream" );
|
res.setHeader( "Content-Type", "text/event-stream" );
|
||||||
res.setHeader( "Cache-Control", "no-cache" );
|
res.setHeader( "Cache-Control", "no-cache" );
|
||||||
|
|
@ -34,8 +264,6 @@ router.post( "/chat", async ( req, res ) =>
|
||||||
const content = chunk.content;
|
const content = chunk.content;
|
||||||
let text = "";
|
let text = "";
|
||||||
|
|
||||||
// RJLog.log( "Chunk", chunk );
|
|
||||||
|
|
||||||
if ( typeof content === "string" )
|
if ( typeof content === "string" )
|
||||||
{
|
{
|
||||||
text = content;
|
text = content;
|
||||||
|
|
@ -44,14 +272,8 @@ router.post( "/chat", async ( req, res ) =>
|
||||||
{
|
{
|
||||||
for ( const c of content )
|
for ( const c of content )
|
||||||
{
|
{
|
||||||
if ( typeof c === "string" )
|
if ( typeof c === "string" ) text += c;
|
||||||
{
|
else if ( c && typeof c === "object" && "text" in c ) text += ( c as any ).text;
|
||||||
text += c;
|
|
||||||
}
|
|
||||||
else if ( c && typeof c === "object" && "text" in c )
|
|
||||||
{
|
|
||||||
text += ( c as any ).text;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -67,18 +289,13 @@ router.post( "/chat", async ( req, res ) =>
|
||||||
}
|
}
|
||||||
|
|
||||||
updateAgentConversation( id, collected.join( "" ) );
|
updateAgentConversation( id, collected.join( "" ) );
|
||||||
|
|
||||||
res.write( JSON.stringify( { type: "DONE" } ) + "\n" );
|
res.write( JSON.stringify( { type: "DONE" } ) + "\n" );
|
||||||
res.end();
|
res.end();
|
||||||
}
|
}
|
||||||
catch ( err )
|
catch ( err )
|
||||||
{
|
{
|
||||||
console.error( err );
|
console.error( err );
|
||||||
|
if ( !res.headersSent ) res.status( 500 ).json( { error: "Failed to get agent response" } );
|
||||||
if ( !res.headersSent )
|
|
||||||
{
|
|
||||||
res.status( 500 ).json( { error: "Failed to get agent response" } );
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} );
|
} );
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,58 @@
|
||||||
<div class="lane">
|
<div class="lane">
|
||||||
<div class="lane-header">To Do</div>
|
<div class="lane-header">To Do</div>
|
||||||
|
|
||||||
|
<task-item class="blue hide-content">
|
||||||
|
<task-title>Rojo Character Editor</task-title>
|
||||||
|
<task-content>
|
||||||
|
Allow changing colors and selecting layers of a Rojo character from within
|
||||||
|
the rojo-settings-panel. Colors map to fill/stroke/both targets on SVG elements;
|
||||||
|
layers toggle visibility of named groups or swap between variants.
|
||||||
|
The appearance field (colors[], layers[]) is already in the settings.rojo schema.
|
||||||
|
</task-content>
|
||||||
|
</task-item>
|
||||||
|
|
||||||
|
<task-item class="blue hide-content">
|
||||||
|
<task-title>Rojo Character Animation Box</task-title>
|
||||||
|
<task-content>
|
||||||
|
Animation system for emotional feedback during conversations.
|
||||||
|
Animations can be scripted (predefined sequences) or driven dynamically by
|
||||||
|
an LLM that emits emotion tags alongside its response. The animation box
|
||||||
|
plays character animations (idle, happy, thinking, surprised, etc.) in the
|
||||||
|
portrait area of the rojo-settings-panel and rojo-chat-panel.
|
||||||
|
</task-content>
|
||||||
|
</task-item>
|
||||||
|
|
||||||
|
<task-item class="blue hide-content">
|
||||||
|
<task-title>File tree: drag-and-drop move for files and directories</task-title>
|
||||||
|
<task-content>
|
||||||
|
Allow files and directories to be moved by dragging them within the file tree.
|
||||||
|
Dragging a file onto a directory moves it inside; dragging a directory onto another
|
||||||
|
directory moves the whole subtree. Use the existing file rename API
|
||||||
|
(POST /api/files/:projectId/rename) — moving is a rename to a new parent path.
|
||||||
|
</task-content>
|
||||||
|
</task-item>
|
||||||
|
|
||||||
|
<task-item class="blue hide-content">
|
||||||
|
<task-title>Tab context menu on right-click (tabs and empty tab bar area)</task-title>
|
||||||
|
<task-content>
|
||||||
|
The tab ⋮ menu already works but should also open on right-click anywhere on
|
||||||
|
the tab bar — both on individual tabs and on the empty space to the right of the tabs.
|
||||||
|
Right-clicking a specific tab should also offer a "Close this tab" action directly.
|
||||||
|
</task-content>
|
||||||
|
</task-item>
|
||||||
|
|
||||||
|
<task-item class="blue hide-content">
|
||||||
|
<task-title>File tree double-click: auto-open or focus existing editor</task-title>
|
||||||
|
<task-content>
|
||||||
|
When a file is double-clicked in the file tree:
|
||||||
|
— If an editor panel that can handle the file type is already open and not pinned,
|
||||||
|
focus that panel's tab and load the file into it.
|
||||||
|
— If no suitable unpinned editor exists, open a new panel of the correct type
|
||||||
|
in the active section before loading the file.
|
||||||
|
Single-click keeps current behaviour (selection only, no open).
|
||||||
|
</task-content>
|
||||||
|
</task-item>
|
||||||
|
|
||||||
<task-item class="blue hide-content">
|
<task-item class="blue hide-content">
|
||||||
<task-title>Remote Projects in Electron</task-title>
|
<task-title>Remote Projects in Electron</task-title>
|
||||||
<task-content>
|
<task-content>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue