tweaking the research implementation, notes now can be taken in trminal and active workspace selector should be functional
This commit is contained in:
@@ -293,9 +293,9 @@ function App() {
|
|||||||
onClearPortfolioAction={handleClearPortfolioAction}
|
onClearPortfolioAction={handleClearPortfolioAction}
|
||||||
resetCommandIndex={resetCommandIndex}
|
resetCommandIndex={resetCommandIndex}
|
||||||
portfolioWorkflow={activePortfolioWorkflow}
|
portfolioWorkflow={activePortfolioWorkflow}
|
||||||
onOpenResearchContext={(intent) => {
|
researchWorkspaces={researchWorkspaces.workspaces}
|
||||||
void handleOpenResearch(intent);
|
activeResearchWorkspaceId={researchWorkspaces.activeWorkspaceId}
|
||||||
}}
|
onCaptureResearchNote={handleCaptureResearchNote}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,20 @@
|
|||||||
import { describe, expect, it } from 'bun:test';
|
import { describe, expect, it } from 'bun:test';
|
||||||
import { renderToStaticMarkup } from 'react-dom/server';
|
import { renderToStaticMarkup } from 'react-dom/server';
|
||||||
import { ResearchMode } from './ResearchMode';
|
import { ResearchMode } from './ResearchMode';
|
||||||
|
import type { ResearchWorkspace } from '../../types/research';
|
||||||
|
|
||||||
|
const createWorkspace = (id: string, ticker: string): ResearchWorkspace => ({
|
||||||
|
id,
|
||||||
|
name: `${ticker} Research`,
|
||||||
|
primaryTicker: ticker,
|
||||||
|
scope: 'single_company',
|
||||||
|
stage: 'capture',
|
||||||
|
defaultView: 'canvas',
|
||||||
|
pinnedNoteIds: [],
|
||||||
|
archived: false,
|
||||||
|
createdAt: '2026-04-09T10:00:00Z',
|
||||||
|
updatedAt: '2026-04-09T10:00:00Z',
|
||||||
|
});
|
||||||
|
|
||||||
describe('ResearchMode', () => {
|
describe('ResearchMode', () => {
|
||||||
it('renders empty state when no workspace is selected', () => {
|
it('renders empty state when no workspace is selected', () => {
|
||||||
@@ -19,6 +33,25 @@ describe('ResearchMode', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(html).toContain('No research workspace selected');
|
expect(html).toContain('No research workspace selected');
|
||||||
expect(html).toContain('Quick capture');
|
expect(html).toContain('Select a research workspace');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the selected workspace details from the active workspace id', () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<ResearchMode
|
||||||
|
workspaces={[createWorkspace('workspace-1', 'AAPL'), createWorkspace('workspace-2', 'NVDA')]}
|
||||||
|
activeWorkspaceId="workspace-2"
|
||||||
|
navigationIntent={null}
|
||||||
|
onSelectWorkspace={() => {}}
|
||||||
|
onEnsureWorkspace={async () => null}
|
||||||
|
onCaptureResearchNote={async () => {
|
||||||
|
throw new Error('not used');
|
||||||
|
}}
|
||||||
|
onConsumeNavigationIntent={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).toContain('NVDA Research');
|
||||||
|
expect(html).toContain('NVDA');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -96,7 +96,12 @@ export const ResearchMode: React.FC<ResearchModeProps> = ({
|
|||||||
const [isLoadingAuditTrail, setIsLoadingAuditTrail] = useState(false);
|
const [isLoadingAuditTrail, setIsLoadingAuditTrail] = useState(false);
|
||||||
const [isCaptureModalOpen, setIsCaptureModalOpen] = useState(false);
|
const [isCaptureModalOpen, setIsCaptureModalOpen] = useState(false);
|
||||||
const [isCreateWorkspaceModalOpen, setIsCreateWorkspaceModalOpen] = useState(false);
|
const [isCreateWorkspaceModalOpen, setIsCreateWorkspaceModalOpen] = useState(false);
|
||||||
const [terminalNoteSeed, setTerminalNoteSeed] = useState<Pick<ResearchComposerState, 'rawText' | 'ticker'> | null>(null);
|
const [terminalNoteSeed, setTerminalNoteSeed] = useState<Pick<
|
||||||
|
ResearchComposerState,
|
||||||
|
'rawText' | 'ticker'
|
||||||
|
> & {
|
||||||
|
contextLabel?: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeWorkspaceId) {
|
if (!activeWorkspaceId) {
|
||||||
@@ -137,6 +142,7 @@ export const ResearchMode: React.FC<ResearchModeProps> = ({
|
|||||||
setTerminalNoteSeed({
|
setTerminalNoteSeed({
|
||||||
rawText: navigationIntent.terminalNoteSeed.rawText ?? '',
|
rawText: navigationIntent.terminalNoteSeed.rawText ?? '',
|
||||||
ticker: navigationIntent.terminalNoteSeed.ticker ?? '',
|
ticker: navigationIntent.terminalNoteSeed.ticker ?? '',
|
||||||
|
contextLabel: navigationIntent.terminalNoteSeed.contextLabel,
|
||||||
});
|
});
|
||||||
setIsCaptureModalOpen(true);
|
setIsCaptureModalOpen(true);
|
||||||
}
|
}
|
||||||
@@ -152,7 +158,14 @@ export const ResearchMode: React.FC<ResearchModeProps> = ({
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const projection = projectionState.projection;
|
const projection = projectionState.projection;
|
||||||
const currentWorkspace = projection?.workspace ?? workspaceState.workspace;
|
const selectedWorkspaceRecord = useMemo(
|
||||||
|
() => workspaces.find((workspace) => workspace.id === activeWorkspaceId) ?? null,
|
||||||
|
[activeWorkspaceId, workspaces],
|
||||||
|
);
|
||||||
|
const currentWorkspace =
|
||||||
|
(projection?.workspace.id === activeWorkspaceId ? projection.workspace : null) ??
|
||||||
|
(workspaceState.workspace?.id === activeWorkspaceId ? workspaceState.workspace : null) ??
|
||||||
|
selectedWorkspaceRecord;
|
||||||
|
|
||||||
const filteredNotes = useMemo(() => {
|
const filteredNotes = useMemo(() => {
|
||||||
const notes = projection?.notes ?? [];
|
const notes = projection?.notes ?? [];
|
||||||
@@ -482,7 +495,10 @@ export const ResearchMode: React.FC<ResearchModeProps> = ({
|
|||||||
defaultWorkspaceId={activeWorkspaceId}
|
defaultWorkspaceId={activeWorkspaceId}
|
||||||
defaultTicker={currentWorkspace?.primaryTicker ?? navigationIntent?.ticker}
|
defaultTicker={currentWorkspace?.primaryTicker ?? navigationIntent?.ticker}
|
||||||
defaultRawText={terminalNoteSeed?.rawText}
|
defaultRawText={terminalNoteSeed?.rawText}
|
||||||
contextLabel={currentWorkspace ? `${currentWorkspace.primaryTicker} workspace` : 'Quick capture'}
|
contextLabel={
|
||||||
|
terminalNoteSeed?.contextLabel ??
|
||||||
|
(currentWorkspace ? `${currentWorkspace.primaryTicker} workspace` : 'Quick capture')
|
||||||
|
}
|
||||||
onWorkspaceChange={onSelectWorkspace}
|
onWorkspaceChange={onSelectWorkspace}
|
||||||
onSubmitCapture={(draft) =>
|
onSubmitCapture={(draft) =>
|
||||||
onCaptureResearchNote({
|
onCaptureResearchNote({
|
||||||
|
|||||||
@@ -300,7 +300,7 @@ export const ResearchSidebar: React.FC<ResearchSidebarProps> = ({
|
|||||||
}`}
|
}`}
|
||||||
title="Switch to Terminal (Cmd+T)"
|
title="Switch to Terminal (Cmd+T)"
|
||||||
>
|
>
|
||||||
<TerminalSquare className="h-4 w-4" />
|
{/* <TerminalSquare className="h-4 w-4" /> */}
|
||||||
Terminal
|
Terminal
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -309,7 +309,7 @@ export const ResearchSidebar: React.FC<ResearchSidebarProps> = ({
|
|||||||
className="flex items-center justify-center gap-2 border border-info bg-info/10 px-3 py-2 text-center text-xs font-mono text-info"
|
className="flex items-center justify-center gap-2 border border-info bg-info/10 px-3 py-2 text-center text-xs font-mono text-info"
|
||||||
title="Research"
|
title="Research"
|
||||||
>
|
>
|
||||||
<NotebookPen className="h-4 w-4" />
|
{/* <NotebookPen className="h-4 w-4" /> */}
|
||||||
Research
|
Research
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -321,7 +321,7 @@ export const ResearchSidebar: React.FC<ResearchSidebarProps> = ({
|
|||||||
: "border-term-border bg-term-surface text-term-text-tertiary hover:border-info/60 hover:text-term-text"
|
: "border-term-border bg-term-surface text-term-text-tertiary hover:border-info/60 hover:text-term-text"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Settings className="h-4 w-4" />
|
{/* <Settings className="h-4 w-4" /> */}
|
||||||
Settings
|
Settings
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import {
|
import {
|
||||||
ChevronLeft,
|
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
Menu,
|
Menu,
|
||||||
PanelLeft,
|
PanelLeft,
|
||||||
|
|||||||
@@ -1,16 +1,22 @@
|
|||||||
import React from 'react';
|
import React from "react";
|
||||||
import { Settings, ChevronLeft, Briefcase, TerminalSquare, NotebookPen } from 'lucide-react';
|
import {
|
||||||
import { PortfolioSummary } from './PortfolioSummary';
|
Settings,
|
||||||
import { TickerHistory } from './TickerHistory';
|
ChevronLeft,
|
||||||
import { Portfolio } from '../../types/financial';
|
Briefcase,
|
||||||
import { TickerHistoryEntry } from '../../types/terminal';
|
TerminalSquare,
|
||||||
|
NotebookPen,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { PortfolioSummary } from "./PortfolioSummary";
|
||||||
|
import { TickerHistory } from "./TickerHistory";
|
||||||
|
import { Portfolio } from "../../types/financial";
|
||||||
|
import { TickerHistoryEntry } from "../../types/terminal";
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
onCommand: (command: string) => void;
|
onCommand: (command: string) => void;
|
||||||
onOpenTerminal: () => void;
|
onOpenTerminal: () => void;
|
||||||
onOpenResearch: () => void;
|
onOpenResearch: () => void;
|
||||||
onOpenSettings: () => void;
|
onOpenSettings: () => void;
|
||||||
activeView: 'terminal' | 'research' | 'settings';
|
activeView: "terminal" | "research" | "settings";
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
tickerHistory: TickerHistoryEntry[];
|
tickerHistory: TickerHistoryEntry[];
|
||||||
@@ -53,7 +59,7 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
<div className="flex-1 overflow-y-auto p-2 space-y-2">
|
<div className="flex-1 overflow-y-auto p-2 space-y-2">
|
||||||
{/* Portfolio icon */}
|
{/* Portfolio icon */}
|
||||||
<button
|
<button
|
||||||
onClick={() => onCommand('/portfolio')}
|
onClick={() => onCommand("/portfolio")}
|
||||||
className="w-full aspect-square bg-term-highlight hover:bg-term-border rounded flex items-center justify-center transition-colors group relative"
|
className="w-full aspect-square bg-term-highlight hover:bg-term-border rounded flex items-center justify-center transition-colors group relative"
|
||||||
title="Portfolio"
|
title="Portfolio"
|
||||||
>
|
>
|
||||||
@@ -73,7 +79,9 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
className="w-full aspect-square bg-term-highlight hover:bg-term-border rounded flex items-center justify-center transition-colors group relative"
|
className="w-full aspect-square bg-term-highlight hover:bg-term-border rounded flex items-center justify-center transition-colors group relative"
|
||||||
title={entry.company.name}
|
title={entry.company.name}
|
||||||
>
|
>
|
||||||
<span className={`text-xs font-mono font-bold ${isPositive ? 'text-positive' : 'text-negative'}`}>
|
<span
|
||||||
|
className={`text-xs font-mono font-bold ${isPositive ? "text-positive" : "text-negative"}`}
|
||||||
|
>
|
||||||
{entry.company.symbol.slice(0, 2)}
|
{entry.company.symbol.slice(0, 2)}
|
||||||
</span>
|
</span>
|
||||||
<span className="absolute left-full ml-2 px-2 py-1 bg-term-highlight border border-term-border rounded text-xs text-term-text opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-20 pointer-events-none">
|
<span className="absolute left-full ml-2 px-2 py-1 bg-term-highlight border border-term-border rounded text-xs text-term-text opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-20 pointer-events-none">
|
||||||
@@ -89,9 +97,9 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
<button
|
<button
|
||||||
onClick={onOpenTerminal}
|
onClick={onOpenTerminal}
|
||||||
className={`group relative flex w-full items-center justify-center rounded border px-2 py-3 transition-colors ${
|
className={`group relative flex w-full items-center justify-center rounded border px-2 py-3 transition-colors ${
|
||||||
activeView === 'terminal'
|
activeView === "terminal"
|
||||||
? 'border-info bg-info/10 text-info'
|
? "border-info bg-info/10 text-info"
|
||||||
: 'border-term-border bg-term-elevated text-term-text-tertiary hover:text-term-text'
|
: "border-term-border bg-term-elevated text-term-text-tertiary hover:text-term-text"
|
||||||
}`}
|
}`}
|
||||||
title="Open terminal"
|
title="Open terminal"
|
||||||
>
|
>
|
||||||
@@ -100,9 +108,9 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
<button
|
<button
|
||||||
onClick={onOpenResearch}
|
onClick={onOpenResearch}
|
||||||
className={`group relative flex w-full items-center justify-center rounded border px-2 py-3 transition-colors ${
|
className={`group relative flex w-full items-center justify-center rounded border px-2 py-3 transition-colors ${
|
||||||
activeView === 'research'
|
activeView === "research"
|
||||||
? 'border-info bg-info/10 text-info'
|
? "border-info bg-info/10 text-info"
|
||||||
: 'border-term-border bg-term-elevated text-term-text-tertiary hover:text-term-text'
|
: "border-term-border bg-term-elevated text-term-text-tertiary hover:text-term-text"
|
||||||
}`}
|
}`}
|
||||||
title="Open research"
|
title="Open research"
|
||||||
>
|
>
|
||||||
@@ -112,9 +120,9 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
<button
|
<button
|
||||||
onClick={onOpenSettings}
|
onClick={onOpenSettings}
|
||||||
className={`group relative flex w-full items-center justify-center rounded border px-2 py-3 transition-colors ${
|
className={`group relative flex w-full items-center justify-center rounded border px-2 py-3 transition-colors ${
|
||||||
activeView === 'settings'
|
activeView === "settings"
|
||||||
? 'border-info bg-info/10 text-info'
|
? "border-info bg-info/10 text-info"
|
||||||
: 'border-term-border bg-term-elevated text-term-text-tertiary hover:text-term-text'
|
: "border-term-border bg-term-elevated text-term-text-tertiary hover:text-term-text"
|
||||||
}`}
|
}`}
|
||||||
title="Open settings"
|
title="Open settings"
|
||||||
>
|
>
|
||||||
@@ -135,8 +143,12 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
<div className="p-4 border-b border-term-border">
|
<div className="p-4 border-b border-term-border">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-lg font-mono font-bold text-term-text">MosaicIQ</h1>
|
<h1 className="text-lg font-mono font-bold text-term-text">
|
||||||
<p className="text-[10px] text-term-text-tertiary font-mono">Financial Terminal v1.0</p>
|
MosaicIQ
|
||||||
|
</h1>
|
||||||
|
<p className="text-[10px] text-term-text-tertiary font-mono">
|
||||||
|
Financial Terminal v1.0
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
@@ -153,12 +165,15 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
{/* Portfolio Summary */}
|
{/* Portfolio Summary */}
|
||||||
<PortfolioSummary
|
<PortfolioSummary
|
||||||
portfolio={portfolio}
|
portfolio={portfolio}
|
||||||
onLoadPortfolio={() => onCommand('/portfolio')}
|
onLoadPortfolio={() => onCommand("/portfolio")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Ticker History - shows only when loaded */}
|
{/* Ticker History - shows only when loaded */}
|
||||||
{isTickerHistoryLoaded && (
|
{isTickerHistoryLoaded && (
|
||||||
<TickerHistory history={tickerHistory} onTickerClick={handleCompanyClick} />
|
<TickerHistory
|
||||||
|
history={tickerHistory}
|
||||||
|
onTickerClick={handleCompanyClick}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -168,42 +183,46 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
<button
|
<button
|
||||||
onClick={onOpenTerminal}
|
onClick={onOpenTerminal}
|
||||||
className={`flex items-center justify-center gap-2 rounded border px-3 py-2 text-center text-xs font-mono transition-colors ${
|
className={`flex items-center justify-center gap-2 rounded border px-3 py-2 text-center text-xs font-mono transition-colors ${
|
||||||
activeView === 'terminal'
|
activeView === "terminal"
|
||||||
? 'border-info bg-info/10 text-info'
|
? "border-info bg-info/10 text-info"
|
||||||
: 'border-term-border bg-term-elevated text-term-text-tertiary hover:border-info hover:text-term-text'
|
: "border-term-border bg-term-elevated text-term-text-tertiary hover:border-info hover:text-term-text"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<TerminalSquare className="h-4 w-4" />
|
{/* <TerminalSquare className="h-4 w-4" /> */}
|
||||||
<span>Terminal</span>
|
<span>Terminal</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onOpenResearch}
|
onClick={onOpenResearch}
|
||||||
className={`flex items-center justify-center gap-2 rounded border px-3 py-2 text-center text-xs font-mono transition-colors ${
|
className={`flex items-center justify-center gap-2 rounded border px-3 py-2 text-center text-xs font-mono transition-colors ${
|
||||||
activeView === 'research'
|
activeView === "research"
|
||||||
? 'border-info bg-info/10 text-info'
|
? "border-info bg-info/10 text-info"
|
||||||
: 'border-term-border bg-term-elevated text-term-text-tertiary hover:border-info hover:text-term-text'
|
: "border-term-border bg-term-elevated text-term-text-tertiary hover:border-info hover:text-term-text"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<NotebookPen className="h-4 w-4" />
|
{/* <NotebookPen className="h-4 w-4" /> */}
|
||||||
<span>Research</span>
|
<span>Research</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onOpenSettings}
|
onClick={onOpenSettings}
|
||||||
className={`flex items-center justify-center gap-2 rounded border px-3 py-2 text-center text-xs font-mono transition-colors ${
|
className={`flex items-center justify-center gap-2 rounded border px-3 py-2 text-center text-xs font-mono transition-colors ${
|
||||||
activeView === 'settings'
|
activeView === "settings"
|
||||||
? 'border-info bg-info/10 text-info'
|
? "border-info bg-info/10 text-info"
|
||||||
: 'border-term-border bg-term-elevated text-term-text-tertiary hover:border-info hover:text-term-text'
|
: "border-term-border bg-term-elevated text-term-text-tertiary hover:border-info hover:text-term-text"
|
||||||
}`}
|
}`}
|
||||||
title="Open settings"
|
title="Open settings"
|
||||||
>
|
>
|
||||||
<Settings className="h-4 w-4" />
|
{/* <Settings className="h-4 w-4" /> */}
|
||||||
<span>Settings</span>
|
<span>Settings</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="text-[10px] text-term-text-tertiary font-mono text-right">
|
<div className="text-[10px] text-term-text-tertiary font-mono text-right">
|
||||||
Press <kbd className="px-1.5 py-0.5 bg-term-highlight rounded text-term-text">Cmd+B</kbd> to toggle
|
Press{" "}
|
||||||
|
<kbd className="px-1.5 py-0.5 bg-term-highlight rounded text-term-text">
|
||||||
|
Cmd+B
|
||||||
|
</kbd>{" "}
|
||||||
|
to toggle
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -408,7 +408,7 @@ export const CommandInput = React.forwardRef<CommandInputHandle, CommandInputPro
|
|||||||
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{!actionComposerActive && shadowState ? (
|
{!actionComposerActive && shadowState ? (
|
||||||
<div className="pointer-events-auto absolute inset-x-0 bottom-[calc(100%+0.75rem)] z-40 overflow-hidden rounded-2xl border border-term-border bg-term-elevated shadow-[0_20px_50px_rgba(0,0,0,0.22)]">
|
<div className="pointer-events-auto absolute inset-x-0 bottom-[calc(100%+0.75rem)] z-40 overflow-visible rounded-2xl border border-term-border bg-term-elevated shadow-[0_20px_50px_rgba(0,0,0,0.22)]">
|
||||||
<div className="flex items-center justify-between border-b border-term-border-subtle bg-term-surface px-3 py-2">
|
<div className="flex items-center justify-between border-b border-term-border-subtle bg-term-surface px-3 py-2">
|
||||||
<span className="text-[9px] font-mono uppercase tracking-wider text-term-text-muted">
|
<span className="text-[9px] font-mono uppercase tracking-wider text-term-text-muted">
|
||||||
Command Shadow
|
Command Shadow
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { ResearchCaptureModal } from '../Research/ResearchCaptureModal';
|
||||||
import { PortfolioWorkflowState } from '../../hooks/usePortfolioWorkflow';
|
import { PortfolioWorkflowState } from '../../hooks/usePortfolioWorkflow';
|
||||||
import { extractResearchContext } from '../../lib/researchContext';
|
|
||||||
import { buildTerminalResearchNoteSeed } from '../../lib/terminalResearchNote';
|
|
||||||
import {
|
import {
|
||||||
ResearchNavigationIntent,
|
resolveTerminalCaptureWorkspaceId,
|
||||||
|
resolveTerminalResearchCaptureIntent,
|
||||||
|
} from '../../lib/terminalResearchCapture';
|
||||||
|
import {
|
||||||
|
NoteCaptureResult,
|
||||||
|
ResearchWorkspace,
|
||||||
} from '../../types/research';
|
} from '../../types/research';
|
||||||
|
import type { ResearchComposerState } from '../../hooks/useResearchComposer';
|
||||||
import {
|
import {
|
||||||
PortfolioAction,
|
PortfolioAction,
|
||||||
PortfolioActionDraft,
|
PortfolioActionDraft,
|
||||||
@@ -29,7 +34,14 @@ interface TerminalProps {
|
|||||||
onClearPortfolioAction: () => void;
|
onClearPortfolioAction: () => void;
|
||||||
resetCommandIndex: () => void;
|
resetCommandIndex: () => void;
|
||||||
portfolioWorkflow: PortfolioWorkflowState;
|
portfolioWorkflow: PortfolioWorkflowState;
|
||||||
onOpenResearchContext: (intent: ResearchNavigationIntent) => void;
|
researchWorkspaces: ResearchWorkspace[];
|
||||||
|
activeResearchWorkspaceId: string | null;
|
||||||
|
onCaptureResearchNote: (args: {
|
||||||
|
draft: ResearchComposerState;
|
||||||
|
fallbackTicker?: string;
|
||||||
|
explicitWorkspaceId?: string | null;
|
||||||
|
autoCreateFromTicker?: boolean;
|
||||||
|
}) => Promise<NoteCaptureResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Terminal: React.FC<TerminalProps> = ({
|
export const Terminal: React.FC<TerminalProps> = ({
|
||||||
@@ -44,66 +56,56 @@ export const Terminal: React.FC<TerminalProps> = ({
|
|||||||
onClearPortfolioAction,
|
onClearPortfolioAction,
|
||||||
resetCommandIndex,
|
resetCommandIndex,
|
||||||
portfolioWorkflow,
|
portfolioWorkflow,
|
||||||
onOpenResearchContext,
|
researchWorkspaces,
|
||||||
|
activeResearchWorkspaceId,
|
||||||
|
onCaptureResearchNote,
|
||||||
}) => {
|
}) => {
|
||||||
const researchContext = extractResearchContext(history);
|
const [terminalCapture, setTerminalCapture] = React.useState<{
|
||||||
const [terminalNoteSeed, setTerminalNoteSeed] = React.useState<{
|
|
||||||
key: string;
|
key: string;
|
||||||
rawText?: string;
|
rawText?: string;
|
||||||
ticker?: string;
|
ticker?: string;
|
||||||
contextLabel: string;
|
contextLabel?: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
const focusResearchCapture = React.useCallback(() => {
|
const defaultWorkspaceId = React.useMemo(
|
||||||
|
() =>
|
||||||
|
resolveTerminalCaptureWorkspaceId({
|
||||||
|
ticker: terminalCapture?.ticker,
|
||||||
|
workspaces: researchWorkspaces,
|
||||||
|
activeWorkspaceId: activeResearchWorkspaceId,
|
||||||
|
}),
|
||||||
|
[activeResearchWorkspaceId, researchWorkspaces, terminalCapture?.ticker],
|
||||||
|
);
|
||||||
|
|
||||||
|
const closeTerminalCapture = React.useCallback(() => {
|
||||||
|
setTerminalCapture(null);
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const element = document.getElementById('research-capture-input');
|
inputRef.current?.focusWithText('');
|
||||||
if (element instanceof HTMLElement) {
|
|
||||||
element.focus();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}, []);
|
}, [inputRef]);
|
||||||
|
|
||||||
const handleTerminalSubmit = React.useCallback(
|
const handleTerminalSubmit = React.useCallback(
|
||||||
(command: string) => {
|
(command: string) => {
|
||||||
const normalized = command.trim().toLowerCase();
|
const researchIntent = resolveTerminalResearchCaptureIntent(command, history);
|
||||||
if (normalized === '/notes add') {
|
if (researchIntent?.terminalNoteSeed) {
|
||||||
setTerminalNoteSeed({
|
setTerminalCapture({
|
||||||
key: `${Date.now()}-note-add`,
|
key: `${Date.now()}-${command.trim().toLowerCase()}`,
|
||||||
ticker: researchContext?.ticker,
|
rawText: researchIntent.terminalNoteSeed.rawText,
|
||||||
contextLabel: researchContext?.label ?? 'Quick note from terminal',
|
ticker: researchIntent.terminalNoteSeed.ticker ?? researchIntent.ticker,
|
||||||
rawText: '',
|
contextLabel: researchIntent.terminalNoteSeed.contextLabel,
|
||||||
});
|
});
|
||||||
focusResearchCapture();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (normalized === '/notes current') {
|
|
||||||
const seed = buildTerminalResearchNoteSeed(history);
|
|
||||||
setTerminalNoteSeed({
|
|
||||||
key: `${Date.now()}-note-current`,
|
|
||||||
ticker: seed?.ticker ?? researchContext?.ticker,
|
|
||||||
contextLabel: seed?.contextLabel ?? researchContext?.label ?? 'Current terminal context',
|
|
||||||
rawText:
|
|
||||||
seed?.rawText ??
|
|
||||||
'Current terminal context is not available yet.\n\nAnalyst note:',
|
|
||||||
});
|
|
||||||
focusResearchCapture();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
onSubmit(command);
|
onSubmit(command);
|
||||||
},
|
},
|
||||||
[focusResearchCapture, history, onSubmit, researchContext?.label, researchContext?.ticker],
|
[history, onSubmit],
|
||||||
);
|
);
|
||||||
|
|
||||||
const captureContextLabel = terminalNoteSeed?.contextLabel ?? researchContext?.label;
|
|
||||||
const captureTicker = terminalNoteSeed?.ticker ?? researchContext?.ticker;
|
|
||||||
const showResearchCapture = Boolean(terminalNoteSeed);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex h-full min-w-0 flex-col overflow-hidden bg-term-bg">
|
<div className="relative flex h-full min-w-0 flex-col overflow-hidden bg-term-bg">
|
||||||
{/* Command Input */}
|
{/* Command Input */}
|
||||||
<div className="relative z-30 flex-shrink-0 overflow-visible border-b border-term-border bg-term-bg p-6">
|
<div className="relative z-30 flex-shrink-0 border-b border-term-border bg-term-bg p-6" style={{ overflow: 'visible' }}>
|
||||||
<CommandInput
|
<CommandInput
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
onSubmit={handleTerminalSubmit}
|
onSubmit={handleTerminalSubmit}
|
||||||
@@ -117,43 +119,6 @@ export const Terminal: React.FC<TerminalProps> = ({
|
|||||||
portfolioDraft={portfolioWorkflow.draft}
|
portfolioDraft={portfolioWorkflow.draft}
|
||||||
lastPortfolioCommand={portfolioWorkflow.lastPortfolioCommand}
|
lastPortfolioCommand={portfolioWorkflow.lastPortfolioCommand}
|
||||||
/>
|
/>
|
||||||
{showResearchCapture ? (
|
|
||||||
<div className="mt-4 flex items-center gap-3 px-4 py-3 bg-term-highlight border border-info rounded">
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="text-xs font-mono text-term-text">
|
|
||||||
Research capture ready - click below to open capture modal
|
|
||||||
</div>
|
|
||||||
<div className="text-[10px] font-mono text-term-text-tertiary mt-0.5">
|
|
||||||
{captureContextLabel} {captureTicker ? `• ${captureTicker}` : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setTerminalNoteSeed(null)}
|
|
||||||
className="px-3 py-1.5 text-xs font-mono text-term-text-tertiary hover:text-term-text transition-colors"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
onOpenResearchContext({
|
|
||||||
preferredView: undefined,
|
|
||||||
terminalNoteSeed: {
|
|
||||||
rawText: terminalNoteSeed?.rawText,
|
|
||||||
ticker: captureTicker,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
setTerminalNoteSeed(null);
|
|
||||||
}}
|
|
||||||
className="px-3 py-1.5 text-xs font-mono bg-info hover:brightness-110 text-term-bg rounded font-semibold transition-colors"
|
|
||||||
>
|
|
||||||
Open Capture
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Terminal Output */}
|
{/* Terminal Output */}
|
||||||
@@ -163,6 +128,24 @@ export const Terminal: React.FC<TerminalProps> = ({
|
|||||||
onRunCommand={onRunCommand}
|
onRunCommand={onRunCommand}
|
||||||
onStartPortfolioAction={onStartPortfolioAction}
|
onStartPortfolioAction={onStartPortfolioAction}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ResearchCaptureModal
|
||||||
|
isOpen={terminalCapture !== null}
|
||||||
|
onClose={closeTerminalCapture}
|
||||||
|
workspaces={researchWorkspaces}
|
||||||
|
defaultWorkspaceId={defaultWorkspaceId}
|
||||||
|
defaultTicker={terminalCapture?.ticker}
|
||||||
|
defaultRawText={terminalCapture?.rawText}
|
||||||
|
seedKey={terminalCapture?.key}
|
||||||
|
contextLabel={terminalCapture?.contextLabel ?? 'Quick capture'}
|
||||||
|
onSubmitCapture={(draft) =>
|
||||||
|
onCaptureResearchNote({
|
||||||
|
draft,
|
||||||
|
fallbackTicker: terminalCapture?.ticker,
|
||||||
|
autoCreateFromTicker: Boolean(terminalCapture?.ticker),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
startTransition,
|
startTransition,
|
||||||
useDeferredValue,
|
useDeferredValue,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useEffectEvent,
|
||||||
useReducer,
|
useReducer,
|
||||||
useRef,
|
useRef,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
@@ -29,6 +30,7 @@ interface ProjectionState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ProjectionAction =
|
type ProjectionAction =
|
||||||
|
| { type: 'reset' }
|
||||||
| { type: 'load_started'; refresh: boolean }
|
| { type: 'load_started'; refresh: boolean }
|
||||||
| { type: 'load_succeeded'; projection: WorkspaceProjection }
|
| { type: 'load_succeeded'; projection: WorkspaceProjection }
|
||||||
| { type: 'load_failed'; error: string }
|
| { type: 'load_failed'; error: string }
|
||||||
@@ -50,6 +52,8 @@ const projectionReducer = (
|
|||||||
action: ProjectionAction,
|
action: ProjectionAction,
|
||||||
): ProjectionState => {
|
): ProjectionState => {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
|
case 'reset':
|
||||||
|
return createProjectionState();
|
||||||
case 'load_started':
|
case 'load_started':
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
@@ -113,31 +117,51 @@ export const useResearchProjection = (
|
|||||||
const [state, dispatch] = useReducer(projectionReducer, undefined, createProjectionState);
|
const [state, dispatch] = useReducer(projectionReducer, undefined, createProjectionState);
|
||||||
const deferredView = useDeferredValue(view);
|
const deferredView = useDeferredValue(view);
|
||||||
const refreshTimeoutRef = useRef<number | null>(null);
|
const refreshTimeoutRef = useRef<number | null>(null);
|
||||||
|
const requestIdRef = useRef(0);
|
||||||
|
const previousWorkspaceIdRef = useRef<string | null>(workspaceId);
|
||||||
|
|
||||||
const loadProjection = useRef(async (refresh: boolean) => {
|
const loadProjection = useEffectEvent(async (refresh: boolean) => {
|
||||||
if (!workspaceId) {
|
const nextWorkspaceId = workspaceId;
|
||||||
|
if (!nextWorkspaceId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const requestId = ++requestIdRef.current;
|
||||||
dispatch({ type: 'load_started', refresh });
|
dispatch({ type: 'load_started', refresh });
|
||||||
try {
|
try {
|
||||||
const projection = await researchBridge.getWorkspaceProjection({
|
const projection = await researchBridge.getWorkspaceProjection({
|
||||||
workspaceId,
|
workspaceId: nextWorkspaceId,
|
||||||
view: deferredView,
|
view: deferredView,
|
||||||
});
|
});
|
||||||
|
if (requestId !== requestIdRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
dispatch({ type: 'load_succeeded', projection });
|
dispatch({ type: 'load_succeeded', projection });
|
||||||
} catch (loadError) {
|
} catch (loadError) {
|
||||||
|
if (requestId !== requestIdRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'load_failed',
|
type: 'load_failed',
|
||||||
error: loadError instanceof Error ? loadError.message : String(loadError),
|
error: loadError instanceof Error ? loadError.message : String(loadError),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}).current;
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!workspaceId) {
|
if (!workspaceId) {
|
||||||
|
previousWorkspaceIdRef.current = null;
|
||||||
|
requestIdRef.current += 1;
|
||||||
|
dispatch({ type: 'reset' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (previousWorkspaceIdRef.current !== workspaceId) {
|
||||||
|
previousWorkspaceIdRef.current = workspaceId;
|
||||||
|
requestIdRef.current += 1;
|
||||||
|
dispatch({ type: 'reset' });
|
||||||
|
}
|
||||||
|
|
||||||
void loadProjection(false);
|
void loadProjection(false);
|
||||||
}, [deferredView, loadProjection, workspaceId]);
|
}, [deferredView, loadProjection, workspaceId]);
|
||||||
|
|
||||||
@@ -149,7 +173,7 @@ export const useResearchProjection = (
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const scheduleRefresh = useRef(() => {
|
const scheduleRefresh = useEffectEvent(() => {
|
||||||
if (refreshTimeoutRef.current != null) {
|
if (refreshTimeoutRef.current != null) {
|
||||||
window.clearTimeout(refreshTimeoutRef.current);
|
window.clearTimeout(refreshTimeoutRef.current);
|
||||||
}
|
}
|
||||||
@@ -158,7 +182,7 @@ export const useResearchProjection = (
|
|||||||
void loadProjection(true);
|
void loadProjection(true);
|
||||||
refreshTimeoutRef.current = null;
|
refreshTimeoutRef.current = null;
|
||||||
}, 150);
|
}, 150);
|
||||||
}).current;
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!workspaceId) {
|
if (!workspaceId) {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export const useResearchWorkspace = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
setWorkspace(null);
|
||||||
setIsLoadingWorkspace(true);
|
setIsLoadingWorkspace(true);
|
||||||
void researchBridge.getResearchWorkspace(workspaceId).then((nextWorkspace) => {
|
void researchBridge.getResearchWorkspace(workspaceId).then((nextWorkspace) => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
|
|||||||
@@ -47,7 +47,11 @@ export const useResearchWorkspaces = ({
|
|||||||
const next = await researchBridge.listResearchWorkspaces();
|
const next = await researchBridge.listResearchWorkspaces();
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
setWorkspaces(next);
|
setWorkspaces(next);
|
||||||
setActiveWorkspaceId((current) => current ?? next[0]?.id ?? null);
|
// Always set activeWorkspaceId if it's null, even if it was previously set
|
||||||
|
setActiveWorkspaceId((current) => {
|
||||||
|
if (current) return current;
|
||||||
|
return next[0]?.id ?? null;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
} catch (loadError) {
|
} catch (loadError) {
|
||||||
setError(loadError instanceof Error ? loadError.message : String(loadError));
|
setError(loadError instanceof Error ? loadError.message : String(loadError));
|
||||||
@@ -60,11 +64,28 @@ export const useResearchWorkspaces = ({
|
|||||||
void loadWorkspaces();
|
void loadWorkspaces();
|
||||||
}, [loadWorkspaces]);
|
}, [loadWorkspaces]);
|
||||||
|
|
||||||
|
// Ensure activeWorkspaceId always points to a valid workspace
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeWorkspaceId) {
|
||||||
|
const exists = workspaces.find((w) => w.id === activeWorkspaceId);
|
||||||
|
if (!exists && workspaces.length > 0) {
|
||||||
|
startTransition(() => {
|
||||||
|
setActiveWorkspaceId(workspaces[0].id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [activeWorkspaceId, workspaces]);
|
||||||
|
|
||||||
useResearchEventSubscriptions({
|
useResearchEventSubscriptions({
|
||||||
onWorkspaceUpdate: ({ workspace }) => {
|
onWorkspaceUpdate: ({ workspace }) => {
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
setWorkspaces((current) => upsertWorkspaceRecord(current, workspace));
|
setWorkspaces((current) => upsertWorkspaceRecord(current, workspace));
|
||||||
setActiveWorkspaceId((current) => current ?? workspace.id);
|
setActiveWorkspaceId((current) => {
|
||||||
|
// If we have a current active workspace, keep it
|
||||||
|
if (current) return current;
|
||||||
|
// Otherwise, use the updated workspace's ID
|
||||||
|
return workspace.id;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
124
MosaicIQ/src/lib/terminalResearchCapture.test.ts
Normal file
124
MosaicIQ/src/lib/terminalResearchCapture.test.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test';
|
||||||
|
import {
|
||||||
|
resolveTerminalCaptureWorkspaceId,
|
||||||
|
resolveTerminalResearchCaptureIntent,
|
||||||
|
} from './terminalResearchCapture';
|
||||||
|
import type { TerminalEntry } from '../types/terminal';
|
||||||
|
import type { ResearchWorkspace } from '../types/research';
|
||||||
|
|
||||||
|
describe('resolveTerminalResearchCaptureIntent', () => {
|
||||||
|
const history: TerminalEntry[] = [
|
||||||
|
{
|
||||||
|
id: 'command-1',
|
||||||
|
type: 'command',
|
||||||
|
content: '/search NVDA',
|
||||||
|
timestamp: new Date('2026-04-09T10:00:00Z'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'panel-1',
|
||||||
|
type: 'panel',
|
||||||
|
content: {
|
||||||
|
type: 'company',
|
||||||
|
data: {
|
||||||
|
symbol: 'NVDA',
|
||||||
|
name: 'NVIDIA',
|
||||||
|
price: 900,
|
||||||
|
change: 12,
|
||||||
|
changePercent: 1.35,
|
||||||
|
marketCap: 2_000_000_000_000,
|
||||||
|
profile: {
|
||||||
|
sector: 'Semiconductors',
|
||||||
|
description: 'GPU leader.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
timestamp: new Date('2026-04-09T10:00:01Z'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
it('creates a direct-open research intent for quick notes', () => {
|
||||||
|
const intent = resolveTerminalResearchCaptureIntent('/notes add', history);
|
||||||
|
|
||||||
|
expect(intent).toEqual({
|
||||||
|
ticker: 'NVDA',
|
||||||
|
terminalNoteSeed: {
|
||||||
|
rawText: '',
|
||||||
|
ticker: 'NVDA',
|
||||||
|
contextLabel: 'NVDA company snapshot',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a seeded research intent for current terminal context', () => {
|
||||||
|
const intent = resolveTerminalResearchCaptureIntent('/notes current', history);
|
||||||
|
|
||||||
|
expect(intent?.ticker).toBe('NVDA');
|
||||||
|
expect(intent?.terminalNoteSeed?.ticker).toBe('NVDA');
|
||||||
|
expect(intent?.terminalNoteSeed?.contextLabel).toBe('NVDA company context');
|
||||||
|
expect(intent?.terminalNoteSeed?.rawText).toContain(
|
||||||
|
'Current terminal context: company snapshot for NVDA',
|
||||||
|
);
|
||||||
|
expect(intent?.terminalNoteSeed?.rawText).toContain('Analyst note:');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers a workspace whose primary ticker matches the terminal capture', () => {
|
||||||
|
const workspaces: ResearchWorkspace[] = [
|
||||||
|
{
|
||||||
|
id: 'workspace-aapl',
|
||||||
|
name: 'AAPL Research',
|
||||||
|
primaryTicker: 'AAPL',
|
||||||
|
scope: 'single_company',
|
||||||
|
stage: 'capture',
|
||||||
|
defaultView: 'canvas',
|
||||||
|
pinnedNoteIds: [],
|
||||||
|
archived: false,
|
||||||
|
createdAt: '2026-04-09T10:00:00Z',
|
||||||
|
updatedAt: '2026-04-09T10:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'workspace-nvda',
|
||||||
|
name: 'NVDA Research',
|
||||||
|
primaryTicker: 'NVDA',
|
||||||
|
scope: 'single_company',
|
||||||
|
stage: 'capture',
|
||||||
|
defaultView: 'canvas',
|
||||||
|
pinnedNoteIds: [],
|
||||||
|
archived: false,
|
||||||
|
createdAt: '2026-04-09T10:00:00Z',
|
||||||
|
updatedAt: '2026-04-09T10:00:00Z',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveTerminalCaptureWorkspaceId({
|
||||||
|
ticker: 'nvda',
|
||||||
|
workspaces,
|
||||||
|
activeWorkspaceId: 'workspace-aapl',
|
||||||
|
}),
|
||||||
|
).toBe('workspace-nvda');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the active workspace when the terminal capture has no ticker', () => {
|
||||||
|
const workspaces: ResearchWorkspace[] = [
|
||||||
|
{
|
||||||
|
id: 'workspace-aapl',
|
||||||
|
name: 'AAPL Research',
|
||||||
|
primaryTicker: 'AAPL',
|
||||||
|
scope: 'single_company',
|
||||||
|
stage: 'capture',
|
||||||
|
defaultView: 'canvas',
|
||||||
|
pinnedNoteIds: [],
|
||||||
|
archived: false,
|
||||||
|
createdAt: '2026-04-09T10:00:00Z',
|
||||||
|
updatedAt: '2026-04-09T10:00:00Z',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveTerminalCaptureWorkspaceId({
|
||||||
|
workspaces,
|
||||||
|
activeWorkspaceId: 'workspace-aapl',
|
||||||
|
}),
|
||||||
|
).toBe('workspace-aapl');
|
||||||
|
});
|
||||||
|
});
|
||||||
66
MosaicIQ/src/lib/terminalResearchCapture.ts
Normal file
66
MosaicIQ/src/lib/terminalResearchCapture.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import type { ResearchNavigationIntent, ResearchWorkspace } from '../types/research';
|
||||||
|
import type { TerminalEntry } from '../types/terminal';
|
||||||
|
import { extractResearchContext } from './researchContext';
|
||||||
|
import { buildTerminalResearchNoteSeed } from './terminalResearchNote';
|
||||||
|
|
||||||
|
const FALLBACK_CURRENT_CONTEXT_TEXT =
|
||||||
|
'Current terminal context is not available yet.\n\nAnalyst note:';
|
||||||
|
|
||||||
|
export const resolveTerminalResearchCaptureIntent = (
|
||||||
|
command: string,
|
||||||
|
history: TerminalEntry[],
|
||||||
|
): ResearchNavigationIntent | null => {
|
||||||
|
const normalized = command.trim().toLowerCase();
|
||||||
|
const researchContext = extractResearchContext(history);
|
||||||
|
|
||||||
|
if (normalized === '/notes add') {
|
||||||
|
return {
|
||||||
|
ticker: researchContext?.ticker,
|
||||||
|
terminalNoteSeed: {
|
||||||
|
rawText: '',
|
||||||
|
ticker: researchContext?.ticker,
|
||||||
|
contextLabel: researchContext?.label ?? 'Quick note from terminal',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized === '/notes current') {
|
||||||
|
const seed = buildTerminalResearchNoteSeed(history);
|
||||||
|
const ticker = seed?.ticker ?? researchContext?.ticker;
|
||||||
|
|
||||||
|
return {
|
||||||
|
ticker,
|
||||||
|
terminalNoteSeed: {
|
||||||
|
rawText: seed?.rawText ?? FALLBACK_CURRENT_CONTEXT_TEXT,
|
||||||
|
ticker,
|
||||||
|
contextLabel:
|
||||||
|
seed?.contextLabel ?? researchContext?.label ?? 'Current terminal context',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveTerminalCaptureWorkspaceId = ({
|
||||||
|
ticker,
|
||||||
|
workspaces,
|
||||||
|
activeWorkspaceId,
|
||||||
|
}: {
|
||||||
|
ticker?: string;
|
||||||
|
workspaces: ResearchWorkspace[];
|
||||||
|
activeWorkspaceId?: string | null;
|
||||||
|
}): string | undefined => {
|
||||||
|
const normalizedTicker = ticker?.trim().toUpperCase();
|
||||||
|
if (normalizedTicker) {
|
||||||
|
return workspaces.find(
|
||||||
|
(workspace) => workspace.primaryTicker.trim().toUpperCase() === normalizedTicker,
|
||||||
|
)?.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeWorkspaceId) {
|
||||||
|
return activeWorkspaceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return workspaces.length === 1 ? workspaces[0].id : undefined;
|
||||||
|
};
|
||||||
@@ -596,6 +596,7 @@ export interface ResearchNavigationIntent {
|
|||||||
terminalNoteSeed?: {
|
terminalNoteSeed?: {
|
||||||
rawText?: string;
|
rawText?: string;
|
||||||
ticker?: string;
|
ticker?: string;
|
||||||
|
contextLabel?: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user