IN-CALL pill shows meeting title from Teams' window text
Some checks failed
CI / build-and-test (push) Failing after 28s

The IN-CALL pill now reads 'IN CALL · Weekly Standup' (or 'IN CALL' if Teams' window doesn't expose a meeting title), so operators using auto-hide know WHICH meeting they're in without restoring the Teams window.

Implementation: TeamsLauncher.GetActiveWindowTitle uses EnumWindows + GetWindowTextW to read every Teams top-level window title (hidden windows too — title bar text is accessible even with SW_HIDE), picks the longest as a heuristic for 'most informative' (Teams creates several windows per process; the call window has the meaningful title). MainViewModel.ExtractMeetingTitle strips the ' | Microsoft Teams' / ' - Microsoft Teams' suffix variations and clamps overly long titles to 50 chars with an ellipsis.

10 new unit tests for ExtractMeetingTitle covering: standard formats with both separators, bare 'Microsoft Teams' (returns empty so the pill stays at 'IN CALL'), long-title truncation, outer-whitespace trimming, unrecognized formats passing through.

169/169 tests passing.
This commit is contained in:
Zac Gaetano 2026-05-10 20:47:43 -04:00
parent b9147183ce
commit 7ef6b8055e
3 changed files with 149 additions and 1 deletions

View file

@ -264,8 +264,58 @@ public static class TeamsLauncher
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool SetForegroundWindow(IntPtr hWnd);
[DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern int GetWindowTextW(IntPtr hWnd, [Out] System.Text.StringBuilder lpString, int nMaxCount);
[DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern int GetWindowTextLengthW(IntPtr hWnd);
private delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
/// <summary>
/// Returns the title bar text of Teams' most-recently-used top-level
/// window, or empty string if Teams isn't running. Modern Teams puts
/// the meeting title in the window title while in a call ("Meeting with
/// Alice | Microsoft Teams"), so this is the cheapest way to surface
/// meeting context to TeamsISO's UI without burning a UIA traversal.
///
/// Includes hidden windows — operators using auto-hide still get the
/// title surfaced, which is the whole point.
/// </summary>
public static string GetActiveWindowTitle()
{
try
{
var teamsPids = new HashSet<uint>(
TeamsProcessNames
.SelectMany(n => Process.GetProcessesByName(n))
.Select(p => { try { return (uint)p.Id; } finally { p.Dispose(); } }));
if (teamsPids.Count == 0) return string.Empty;
string longestTitle = string.Empty;
EnumWindows((hWnd, _) =>
{
if (GetWindow(hWnd, GW_OWNER) != IntPtr.Zero) return true;
GetWindowThreadProcessId(hWnd, out var pid);
if (!teamsPids.Contains(pid)) return true;
var len = GetWindowTextLengthW(hWnd);
if (len <= 0) return true;
var sb = new System.Text.StringBuilder(len + 1);
GetWindowTextW(hWnd, sb, sb.Capacity);
var title = sb.ToString();
// Teams creates a few top-level windows per process; the
// call/meeting window has the longest title (other windows
// tend to just be "Microsoft Teams"). Pick the longest one
// as a heuristic for "most informative".
if (title.Length > longestTitle.Length) longestTitle = title;
return true;
}, IntPtr.Zero);
return longestTitle;
}
catch { return string.Empty; }
}
/// <summary>
/// Enumerate every visible top-level window owned by any running Teams
/// process. "Top-level" means GetWindow(GW_OWNER) is null (the window is

View file

@ -524,6 +524,34 @@ public sealed class MainViewModel : ObservableObject, IDisposable
Toast.Show($"Stopped {enabled.Length} ISO(s)");
}
/// <summary>
/// Pull the meaningful "meeting title" out of Teams' raw window title.
/// Teams uses formats like:
/// "Weekly Standup | Microsoft Teams"
/// "Meeting with Alice | Microsoft Teams"
/// "Microsoft Teams" (no meeting, just the app)
/// Strip the trailing " | Microsoft Teams" so the IN-CALL pill stays
/// short and readable. Truncate beyond 50 chars so a long meeting
/// subject doesn't push the rest of the IN-CALL bar off screen.
/// </summary>
internal static string ExtractMeetingTitle(string windowTitle)
{
if (string.IsNullOrWhiteSpace(windowTitle)) return string.Empty;
var t = windowTitle.Trim();
// Common separator patterns Teams uses across locales.
foreach (var sep in new[] { " | Microsoft Teams", " | Teams", " - Microsoft Teams", " - Teams" })
{
var idx = t.IndexOf(sep, StringComparison.Ordinal);
if (idx > 0) { t = t.Substring(0, idx).Trim(); break; }
}
// If after stripping we're left with just "Microsoft Teams" the
// window has no meeting context — return empty so the pill stays
// at "IN CALL" without a stale title.
if (t.Equals("Microsoft Teams", StringComparison.OrdinalIgnoreCase)) return string.Empty;
if (t.Length > 50) t = t.Substring(0, 47) + "…";
return t;
}
private void OnStatsTick(object? sender, EventArgs e)
{
foreach (var vm in Participants)
@ -628,10 +656,13 @@ public sealed class MainViewModel : ObservableObject, IDisposable
try
{
var inCall = TeamsControlBridge.IsInCall();
var title = inCall ? ExtractMeetingTitle(TeamsLauncher.GetActiveWindowTitle()) : null;
_dispatcher.InvokeAsync(() =>
{
IsTeamsInCall = inCall;
TeamsMeetingState = inCall ? "IN CALL" : "READY";
TeamsMeetingState = inCall
? (string.IsNullOrEmpty(title) ? "IN CALL" : $"IN CALL · {title}")
: "READY";
});
}
catch { /* UIA flakiness shouldn't crash the stats tick */ }

View file

@ -0,0 +1,67 @@
using TeamsISO.App.ViewModels;
using Xunit;
namespace TeamsISO.App.Tests;
/// <summary>
/// Validates the title-stripping heuristic used by the IN-CALL bar pill.
/// Teams' raw window title is the format the user actually sees (or doesn't —
/// when auto-hide is on); we want to surface the meaningful meeting-name
/// portion without the "| Microsoft Teams" suffix bloating the pill.
/// </summary>
public class MeetingTitleExtractionTests
{
[Theory]
[InlineData("Weekly Standup | Microsoft Teams", "Weekly Standup")]
[InlineData("Meeting with Alice | Microsoft Teams", "Meeting with Alice")]
[InlineData("Q4 Planning - Microsoft Teams", "Q4 Planning")]
[InlineData("Meeting | Teams", "Meeting")]
public void StripsTeamsSuffix(string raw, string expected)
{
Assert.Equal(expected, MainViewModel.ExtractMeetingTitle(raw));
}
[Fact]
public void EmptyInput_ReturnsEmpty()
{
Assert.Equal(string.Empty, MainViewModel.ExtractMeetingTitle(string.Empty));
}
[Fact]
public void Whitespace_ReturnsEmpty()
{
Assert.Equal(string.Empty, MainViewModel.ExtractMeetingTitle(" "));
}
[Fact]
public void BareAppTitle_ReturnsEmpty()
{
// "Microsoft Teams" alone means no meeting context — pill should
// stay at plain "IN CALL" rather than appending a meaningless title.
Assert.Equal(string.Empty, MainViewModel.ExtractMeetingTitle("Microsoft Teams"));
}
[Fact]
public void LongTitle_GetsTruncated()
{
var long_ = new string('A', 100) + " | Microsoft Teams";
var result = MainViewModel.ExtractMeetingTitle(long_);
Assert.True(result.Length <= 50);
Assert.EndsWith("…", result);
}
[Fact]
public void OuterWhitespaceIsTrimmed()
{
// Real Teams uses single-space format " | Microsoft Teams"; we only
// promise to trim outer whitespace, not normalize internal padding.
Assert.Equal("Project sync", MainViewModel.ExtractMeetingTitle(" Project sync | Microsoft Teams "));
}
[Fact]
public void TitleWithoutSeparator_PassesThrough()
{
// If Teams emits an unrecognized format, return it as-is (clamped to 50).
Assert.Equal("Quick chat", MainViewModel.ExtractMeetingTitle("Quick chat"));
}
}