Skip to content

Commit bbf304a

Browse files
Share toast, chat #SegID links, fix Cell Library username resolution
- Share button: shows "Link copied to clipboard" toast for 2.5s - Chat: #SegmentID (5+ digits) becomes a clickable green pill that loads the segment in the viewer - Cell Library: fix username resolution reactivity — replaced Map with reactive object so names update after async Supabase lookup - Chat message parser now detects both URLs and #SegID patterns Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent bba96bb commit bbf304a

File tree

4 files changed

+112
-18
lines changed

4 files changed

+112
-18
lines changed

src/components/CellLibraryPanel.vue

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -195,32 +195,37 @@ const completedCount = computed(() => cells.value.filter(c => c.status === 'comp
195195
const claimedCount = computed(() => cells.value.filter(c => c.status === 'assigned' || c.status === 'in_progress').length);
196196
197197
// ── User name lookup (for claimed tab) ───────────────────────────────
198-
const userNameCache = ref<Map<string, string>>(new Map());
198+
const userNameCache = ref<Record<string, string>>({});
199+
const pendingResolves = new Set<string>();
199200
async function resolveUserName(userId: string): Promise<string> {
200-
if (userNameCache.value.has(userId)) return userNameCache.value.get(userId)!;
201+
if (userNameCache.value[userId]) return userNameCache.value[userId];
202+
if (pendingResolves.has(userId)) return userId.slice(0, 8);
203+
pendingResolves.add(userId);
201204
try {
202205
const { data } = await (await import('../supabase')).default
203206
.from('users')
204-
.select('display_name, email')
207+
.select('display_name, middleauth_email')
205208
.eq('id', userId)
206209
.single();
207-
const name = data?.display_name || data?.email?.split('@')[0] || userId.slice(0, 8);
208-
userNameCache.value.set(userId, name);
210+
const name = data?.display_name || data?.middleauth_email?.split('@')[0] || userId.slice(0, 8);
211+
userNameCache.value = { ...userNameCache.value, [userId]: name };
209212
return name;
210213
} catch {
211214
const fallback = userId.slice(0, 8);
212-
userNameCache.value.set(userId, fallback);
215+
userNameCache.value = { ...userNameCache.value, [userId]: fallback };
213216
return fallback;
217+
} finally {
218+
pendingResolves.delete(userId);
214219
}
215220
}
216221
function getCachedUserName(userId: string): string {
217222
if (!userId) return '?';
218223
if (userId === backend.userId) return 'You';
219-
if (!userNameCache.value.has(userId)) {
220-
resolveUserName(userId); // fire-and-forget
221-
return userId.slice(0, 8) + '';
224+
if (!userNameCache.value[userId]) {
225+
resolveUserName(userId);
226+
return '';
222227
}
223-
return userNameCache.value.get(userId)!;
228+
return userNameCache.value[userId];
224229
}
225230
226231
// ── Actions ──────────────────────────────────────────────────────────

src/components/ChatPanel.vue

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,34 @@ function send() {
179179
inputEl.value?.focus();
180180
}
181181
182+
// ── Load segment from #SegID click ──
183+
function loadSegment(segRef: string) {
184+
const segId = segRef.replace('#', '');
185+
try {
186+
const viewer = (window as any)['viewer'];
187+
if (!viewer) return;
188+
// Find segmentation layer and add segment
189+
const segLayer = viewer.layerManager?.managedLayers?.find((x: any) => {
190+
const layer = x.layer;
191+
if (!layer) return false;
192+
const cn = layer.constructor?.name || '';
193+
return cn.includes('Segmentation') || layer.type === 'segmentation' || x.initialSpecification?.type === 'segmentation';
194+
});
195+
if (segLayer?.layer) {
196+
const groupState = segLayer.layer.displayState?.segmentationGroupState?.value;
197+
if (groupState?.visibleSegments) {
198+
const { Uint64 } = require('neuroglancer/util/uint64');
199+
const seg = Uint64.parseString(segId);
200+
if (!groupState.visibleSegments.has(seg)) {
201+
groupState.visibleSegments.add(seg);
202+
}
203+
}
204+
}
205+
} catch (e) {
206+
console.warn('[chat] loadSegment error:', e);
207+
}
208+
}
209+
182210
// ── Scroll handling (inverted scroll) ──
183211
function handleScroll() {
184212
const el = scrollContainer.value;
@@ -263,6 +291,7 @@ function toggleCollapse() {
263291
<template v-for="(part, pi) in msg.parts" :key="pi">
264292
<template v-if="part.type === 'sender'"></template>
265293
<a v-else-if="part.type === 'link'" :href="part.text" target="_blank" rel="noopener" class="nge-chat-link">{{ part.text }}</a>
294+
<button v-else-if="part.type === 'segment'" class="nge-chat-seg-link" @click="loadSegment(part.text)" :title="'Load segment ' + part.text.slice(1)">{{ part.text }}</button>
266295
<span v-else class="nge-chat-msg-text">{{ part.text }}</span>
267296
</template>
268297
</div>
@@ -511,6 +540,22 @@ function toggleCollapse() {
511540
}
512541
.nge-chat-link:hover { text-decoration: underline; }
513542
543+
.nge-chat-seg-link {
544+
background: rgba(0, 220, 120, 0.1);
545+
border: 1px solid rgba(0, 220, 120, 0.25);
546+
color: #80ffc0;
547+
padding: 1px 6px;
548+
border-radius: 4px;
549+
font-size: 12px;
550+
font-family: 'JetBrains Mono', 'Fira Code', monospace;
551+
cursor: pointer;
552+
transition: all 0.15s;
553+
}
554+
.nge-chat-seg-link:hover {
555+
background: rgba(0, 220, 120, 0.2);
556+
border-color: rgba(0, 220, 120, 0.4);
557+
}
558+
514559
/* System messages */
515560
.nge-chat-sys {
516561
font-size: 11px;

src/components/ExtensionBar.vue

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,12 +57,29 @@ const invalidLogins = computed(() => login.sessions.filter(x => x.status !== und
5757
5858
const {volumes} = useVolumesStore();
5959
60+
const shareCopied = ref(false);
61+
let shareCopiedTimer: ReturnType<typeof setTimeout> | null = null;
62+
6063
onMounted(() => {
6164
// Keep Pyr icon in top-left (don't overwrite with CaveLogo)
6265
document.addEventListener('nge:open-profile', ((e: CustomEvent) => {
6366
profileUserId.value = e.detail?.userId || null;
6467
showProfile.value = true;
6568
}) as EventListener);
69+
70+
// Detect Share button click → show "Link copied" toast
71+
const topBar = document.getElementById('insertNGTopBar');
72+
if (topBar) {
73+
topBar.addEventListener('click', (e) => {
74+
const target = e.target as HTMLElement;
75+
const shareBtn = target.closest('[title*="hare"], [class*="share" i]');
76+
if (shareBtn) {
77+
if (shareCopiedTimer) clearTimeout(shareCopiedTimer);
78+
shareCopied.value = true;
79+
shareCopiedTimer = setTimeout(() => { shareCopied.value = false; }, 2500);
80+
}
81+
});
82+
}
6683
});
6784
6885
const statsStore = useUserStatsStore();
@@ -233,6 +250,9 @@ function activateTool(toolType: 'multicut' | 'merge') {
233250
</a>
234251
</div>
235252
<div id="insertNGTopBar" class="flex-fill"></div>
253+
<transition name="nge-share-toast">
254+
<div v-if="shareCopied" class="nge-share-toast">Link copied to clipboard</div>
255+
</transition>
236256
<button v-if="volumes.length" @click="showModal = true">Volumes ({{ volumes.length }})</button>
237257
<div v-if="login.sessions.length > 0 && stats.currentStreak > 0"
238258
class="nge-streak-chip" title="Your current editing streak">
@@ -453,6 +473,30 @@ function activateTool(toolType: 'multicut' | 'merge') {
453473
}
454474
.nge-pyr-logo:hover { opacity: 1; }
455475
476+
/* ── Share toast ── */
477+
.nge-share-toast {
478+
position: absolute;
479+
top: 44px;
480+
left: 50%;
481+
transform: translateX(-50%);
482+
background: rgba(8, 24, 40, 0.94);
483+
border: 1px solid rgba(74, 158, 255, 0.3);
484+
color: #a0d0ff;
485+
padding: 8px 18px;
486+
border-radius: 8px;
487+
font-size: 12px;
488+
font-weight: 500;
489+
white-space: nowrap;
490+
z-index: 9999;
491+
backdrop-filter: blur(10px);
492+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
493+
pointer-events: none;
494+
}
495+
.nge-share-toast-enter-active { transition: all 0.25s ease-out; }
496+
.nge-share-toast-leave-active { transition: all 0.3s ease-in; }
497+
.nge-share-toast-enter-from { opacity: 0; transform: translateX(-50%) translateY(-6px); }
498+
.nge-share-toast-leave-to { opacity: 0; transform: translateX(-50%) translateY(-4px); }
499+
456500
.nge-streak-chip {
457501
display: flex;
458502
align-items: center;

src/store.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2913,7 +2913,7 @@ export const useVolumesStore = defineStore('volumes', () => {
29132913
// ════════════════════════════════════════════════════════════════════════════════
29142914

29152915
export interface MessagePart {
2916-
type: 'text' | 'link' | 'sender';
2916+
type: 'text' | 'link' | 'sender' | 'segment';
29172917
text: string;
29182918
}
29192919

@@ -2926,21 +2926,21 @@ export interface ChatMessage {
29262926
parts: MessagePart[];
29272927
}
29282928

2929-
/** Parse message text into parts (text + auto-detected links) */
2929+
/** Parse message text into parts (text + auto-detected links + #SegID references) */
29302930
function parseMessageParts(name: string, text: string): MessagePart[] {
29312931
const parts: MessagePart[] = [{ type: 'sender', text: name }];
2932-
// Split on URLs
2933-
const urlRegex = /(https?:\/\/\S+)/g;
2934-
const segments = text.split(urlRegex);
2932+
// Split on URLs and #SegmentID references
2933+
const tokenRegex = /(https?:\/\/\S+|#\d{5,})/g;
2934+
const segments = text.split(tokenRegex);
29352935
for (const seg of segments) {
29362936
if (!seg) continue;
2937-
if (urlRegex.test(seg)) {
2937+
if (/^https?:\/\//.test(seg)) {
29382938
parts.push({ type: 'link', text: seg });
2939+
} else if (/^#\d{5,}$/.test(seg)) {
2940+
parts.push({ type: 'segment', text: seg });
29392941
} else {
29402942
parts.push({ type: 'text', text: seg });
29412943
}
2942-
// Reset regex lastIndex since we reuse it
2943-
urlRegex.lastIndex = 0;
29442944
}
29452945
return parts;
29462946
}

0 commit comments

Comments
 (0)